This commit is contained in:
吴红兵
2025-12-02 10:37:49 +08:00
commit 1f645dad3e
1183 changed files with 147673 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
<template>
<el-dialog :title="form.id ? '编辑' : '新增'" v-model="visible"
:close-on-click-modal="false" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="90px" v-loading="loading">
<el-row :gutter="24">
<el-col :span="12" class="mb20">
<el-form-item label="用户ID" prop="userId">
<el-input v-model="form.userId" placeholder="请输入用户ID"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="提示令牌数量" prop="promptTokens">
<el-input v-model="form.promptTokens" placeholder="请输入提示令牌数量"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="补全令牌数量" prop="completionTokens">
<el-input v-model="form.completionTokens" placeholder="请输入补全令牌数量"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="模型名称" prop="model">
<el-input v-model="form.model" placeholder="请输入模型名称"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="请求ID" prop="reqid">
<el-input v-model="form.reqid" placeholder="请输入请求ID"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="IP地址" prop="ip">
<el-input v-model="form.ip" placeholder="请输入IP地址"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="备注" prop="note">
<el-input v-model="form.note" placeholder="请输入备注"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="令牌ID" prop="tokenId">
<el-input v-model="form.tokenId" placeholder="请输入令牌ID"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="令牌数量" prop="tokens">
<el-input v-model="form.tokens" placeholder="请输入令牌数量"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="令牌类型 0 系统 1 用户" prop="tokenType">
<el-input v-model="form.tokenType" placeholder="请输入令牌类型 0 系统 1 用户"/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="AiBillDialog">
import { useDict } from '/@/hooks/dict';
import { useMessage } from "/@/hooks/message";
import { getObj, addObj, putObj } from '/@/api/knowledge/aiBill'
import { rule } from '/@/utils/validate';
const emit = defineEmits(['refresh']);
// 定义变量内容
const dataFormRef = ref();
const visible = ref(false)
const loading = ref(false)
// 定义字典
const { yes_no_type } = useDict('yes_no_type')
// 提交表单数据
const form = reactive({
id:'',
userId: '',
promptTokens: '',
completionTokens: '',
model: '',
reqid: '',
ip: '',
note: '',
tokenId: '',
tokens: '',
tokenType: '',
});
// 定义校验规则
const dataRules = ref({
})
// 打开弹窗
const openDialog = (id: string) => {
visible.value = true
form.id = ''
// 重置表单数据
nextTick(() => {
dataFormRef.value?.resetFields();
});
// 获取aiBill信息
if (id) {
form.id = id
getaiBillData(id)
}
};
// 提交
const onSubmit = async () => {
const valid = await dataFormRef.value.validate().catch(() => {});
if (!valid) return false;
try {
loading.value = true;
form.id ? await putObj(form) : await addObj(form);
useMessage().success(form.id ? '修改成功' : '添加成功');
visible.value = false;
emit('refresh');
} catch (err: any) {
useMessage().error(err.msg);
} finally {
loading.value = false;
}
};
// 初始化表单数据
const getaiBillData = (id: string) => {
// 获取数据
loading.value = true
getObj(id).then((res: any) => {
Object.assign(form, res.data)
}).finally(() => {
loading.value = false
})
};
// 暴露变量
defineExpose({
openDialog
});
</script>

View File

@@ -0,0 +1,149 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<!-- 顶部折线图-->
<bill-line-chart/>
<el-row v-show="showSearch">
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
<el-form-item label="用户名" prop="username">
<el-input placeholder="请输入用户名" v-model="state.queryForm.username"/>
</el-form-item>
<el-form-item label="系统调用" prop="tokenType">
<el-radio-group v-model="state.queryForm.tokenType">
<el-radio :key="index" :label="item.value" border v-for="(item, index) in yes_no_type">{{
item.label
}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="getDataList">
查询
</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row>
<div class="mb8" style="width: 100%">
<el-button plain :disabled="multiple" icon="Delete" type="primary"
v-auth="'knowledge_aiBill_del'" @click="handleDelete(selectObjs)">
删除
</el-button>
<right-toolbar v-model:showSearch="showSearch" :export="'knowledge_aiBill_export'"
@exportExcel="exportExcel" class="ml10 mr20" style="float: right;"
@queryTable="getDataList"></right-toolbar>
</div>
</el-row>
<el-table :data="state.dataList" v-loading="state.loading" border
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
@selection-change="selectionChangHandle"
@sort-change="sortChangeHandle">
<el-table-column type="selection" width="40" align="center"/>
<el-table-column type="index" label="#" width="40"/>
<el-table-column prop="createBy" label="用户名" show-overflow-tooltip/>
<el-table-column prop="model" label="模型名称" show-overflow-tooltip/>
<el-table-column prop="tokens" label="tokens" sortable="custom" show-overflow-tooltip/>
<el-table-column prop="promptTokens" label="prompt tokens" show-overflow-tooltip/>
<el-table-column prop="completionTokens" label="completion" show-overflow-tooltip/>
<el-table-column prop="ip" label="IP地址" show-overflow-tooltip/>
<el-table-column prop="tokenType" label="系统调用" show-overflow-tooltip>
<template #default="scope">
<dict-tag :options="yes_no_type" :value="scope.row.tokenType"></dict-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="调用时间" show-overflow-tooltip/>
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button icon="delete" text type="primary" v-auth="'knowledge_aiBill_del'"
@click="handleDelete([scope.row.id])">删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination"/>
</div>
<!-- 编辑新增 -->
<form-dialog ref="formDialogRef" @refresh="getDataList(false)"/>
</div>
</template>
<script setup lang="ts" name="systemAiBill">
import {BasicTableProps, useTable} from "/@/hooks/table";
import {fetchList, delObjs} from "/@/api/knowledge/aiBill";
import {useMessage, useMessageBox} from "/@/hooks/message";
import {useDict} from '/@/hooks/dict';
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
const BillLineChart = defineAsyncComponent(() => import('./line-chart.vue'));
// 定义查询字典
const {yes_no_type} = useDict('yes_no_type')
// 定义变量内容
const formDialogRef = ref()
// 搜索变量
const queryRef = ref()
const showSearch = ref(true)
// 多选变量
const selectObjs = ref([]) as any
const multiple = ref(true)
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {},
pageList: fetchList,
descs: ['create_time']
})
// table hook
const {
getDataList,
currentChangeHandle,
sizeChangeHandle,
sortChangeHandle,
downBlobFile,
tableStyle
} = useTable(state)
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
queryRef.value?.resetFields()
// 清空多选
selectObjs.value = []
getDataList()
}
// 导出excel
const exportExcel = () => {
downBlobFile('/knowledge/aiBill/export', Object.assign(state.queryForm, {ids: selectObjs}), 'aiBill.xlsx')
}
// 多选事件
const selectionChangHandle = (objs: { id: string }[]) => {
selectObjs.value = objs.map(({id}) => id);
multiple.value = !objs.length;
};
// 删除操作
const handleDelete = async (ids: string[]) => {
try {
await useMessageBox().confirm('此操作将永久删除');
} catch {
return;
}
try {
await delObjs(ids);
getDataList();
useMessage().success('删除成功');
} catch (err: any) {
useMessage().error(err.msg);
}
};
</script>

View File

@@ -0,0 +1,163 @@
<template>
<v-chart class="w-full h-80" :option="option" />
</template>
<script setup lang="ts" name="log-line-chart">
import VChart from 'vue-echarts';
import { formatPast } from '/@/utils/formatTime';
import { getSum } from '/@/api/knowledge/aiBill';
import { use } from 'echarts/core';
import { LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TitleComponent, ToolboxComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
use([TitleComponent, TooltipComponent, LegendComponent, ToolboxComponent, GridComponent, LineChart, CanvasRenderer]);
interface BillSumItem {
date: string;
total_tokens: number;
}
const option = reactive({
title: {
textStyle: {
fontSize: 16,
fontWeight: 500,
color: '#303133',
},
padding: [20, 0, 0, 20],
},
tooltip: {
trigger: 'axis',
backgroundColor: '#ffffff',
borderRadius: 8,
padding: [12, 16],
borderWidth: 0,
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowBlur: 12,
shadowOffsetY: 4,
textStyle: {
color: '#303133',
},
axisPointer: {
type: 'line',
lineStyle: {
color: '#ebeef5',
width: 1,
type: 'dashed',
},
},
},
legend: {
icon: 'circle',
itemWidth: 8,
itemHeight: 8,
textStyle: {
color: '#606266',
fontSize: 12,
},
right: '20px',
top: '20px',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: [],
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
color: '#909399',
fontSize: 12,
margin: 16,
},
splitLine: {
show: true,
lineStyle: {
color: '#ebeef5',
type: 'dashed',
},
},
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
color: '#ebeef5',
type: 'dashed',
},
},
axisLabel: {
color: '#909399',
fontSize: 12,
margin: 16,
},
axisLine: {
show: false,
},
axisTick: {
show: false,
},
},
series: [
{
type: 'line',
stack: 'Total',
data: [],
smooth: true,
symbol: 'circle',
symbolSize: 8,
itemStyle: {
color: '#79bbff',
borderColor: '#fff',
borderWidth: 2,
},
lineStyle: {
width: 3,
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(121, 187, 255, 0.2)',
},
{
offset: 1,
color: 'rgba(121, 187, 255, 0.02)',
},
],
},
},
},
],
});
onMounted(() => {
getSum().then((res) => {
option.xAxis.data = res.data.map((item: BillSumItem) => formatPast(new Date(item.date), 'mm-dd'));
option.series[0].data = res.data.map((item: BillSumItem) => item.total_tokens);
});
});
</script>
<style scoped>
:deep(.echarts) {
background: transparent;
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<chat-index :datasetId="datasetId" />
</template>
<script setup lang="ts">
import ChatIndex from './index.vue';
import { ref, onBeforeMount } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const datasetId = ref<string>('');
onBeforeMount(() => {
const parts = route.path.split('/');
const lastParam = parts[parts.length - 1];
datasetId.value = lastParam;
});
</script>

View File

@@ -0,0 +1,175 @@
<template>
<div class="flex flex-col chat-content-container">
<div ref="scrollContainerRef" class="overflow-auto flex-grow scroll-container">
<div class="flex-1 mx-4 mb-4" ref="chatListDom">
<!-- Welcome area -->
<div class="flex flex-col py-4 rounded-lg group">
<chat-welcome
v-if="selectedKnowledge.welcomeMsg"
:selected-knowledge="selectedKnowledge"
:prologue-list="prologueList"
@quick-problem="quickProblemHandle"
/>
<!-- Chat messages -->
<chat-message
v-for="(item, index) in filteredMessageList"
:key="index"
:message="item"
:index="index"
:selected-knowledge="selectedKnowledge"
:message-list="messageList"
:is-finish="isFinish"
:baseURL="baseURL"
:role-alias="roleAlias"
:collapse-states="collapseStates"
@copy-text="copyText"
@regenerate="regenerateText"
@stop-generate="stopGenerateText"
ref="chatMessageRef"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useScroll } from '@vueuse/core';
import ChatWelcome from './chat-welcome.vue';
import ChatMessage from './chat-message.vue';
import type { ChatMessage as MessageType, Dataset, PrologueItem } from '../ts/index';
const props = defineProps({
messageList: {
type: Array as () => MessageType[],
required: true,
},
prologueList: {
type: Array as () => PrologueItem[],
required: true,
},
selectedKnowledge: {
type: Object as () => Dataset,
required: true,
},
isFinish: {
type: Boolean,
default: true,
},
roleAlias: {
type: Object,
required: true,
},
collapseStates: {
type: Object,
required: true,
},
});
const emit = defineEmits(['quickProblem', 'copyText', 'regenerate', 'stopGenerate', 'scrollToBottom']);
const scrollContainerRef = ref<HTMLElement | null>(null);
const chatListDom = ref<HTMLDivElement>();
const chatMessageRef = ref();
const baseURL = import.meta.env.VITE_APP_BASE_API;
// 使用VueUse的useScroll来监听和控制滚动
useScroll(scrollContainerRef, {
behavior: 'smooth',
onScroll: () => {
// 可以在这里添加额外的滚动处理逻辑
},
});
// Computed property to filter message list and prevent recursive updates
const filteredMessageList = computed(() => {
return props.messageList.filter((item) => item !== undefined && item !== null);
});
// Pass through events to parent
const quickProblemHandle = (val: string) => {
emit('quickProblem', val);
};
const copyText = (content: string) => {
emit('copyText', content);
};
const regenerateText = () => {
emit('regenerate');
};
const stopGenerateText = () => {
emit('stopGenerate');
};
// 使用VueUse的useScroll来滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (!scrollContainerRef.value) return;
// 计算需要滚动到的位置 - 内容高度
const scrollHeight = scrollContainerRef.value.scrollHeight;
// 使用VueUse的useScroll滚动到底部
scrollContainerRef.value.scrollTop = scrollHeight;
});
};
onMounted(() => {
// 等待DOM完全渲染后滚动到底部
nextTick(() => {
scrollToBottom();
});
});
// 监听消息列表长度变化,当有新消息时滚动到底部
watch(
() => props.messageList.length,
(newLength, oldLength) => {
if (newLength > oldLength) {
// 新消息添加 - 始终滚动到底部
scrollToBottom();
}
}
);
// 将scrollToBottom方法暴露给父组件
defineExpose({
scrollToBottom,
});
</script>
<style scoped>
.flex-grow {
flex-grow: 1;
}
.chat-content-container {
/* 设置适当的高度,为输入区域留出空间 */
height: calc(100vh - 120px);
overflow: hidden;
}
.scroll-container {
height: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
}
.scroll-container::-webkit-scrollbar {
width: 6px;
}
.scroll-container::-webkit-scrollbar-track {
background: transparent;
}
.scroll-container::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 3px;
}
</style>

View File

@@ -0,0 +1,263 @@
<template>
<!-- 侧边栏容器 -->
<div class="flex-none w-72 bg-white border-r border-gray-300 dark:bg-gray-800 dark:border-gray-700">
<!-- 带有新建聊天按钮的头部 -->
<div class="flex justify-between items-center p-4 border-b border-gray-300 dark:border-gray-700">
<button @click="createNewChat" class="flex items-center space-x-2 text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span>新建聊天</span>
</button>
<button @click="clearAllChats" class="flex items-center text-sm text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
<!-- 聊天历史列表 -->
<div class="overflow-y-auto overflow-x-hidden p-2 max-h-screen">
<div v-if="recentConversations.length === 0" class="py-4 text-center text-gray-500">暂无历史记录</div>
<div
v-for="conversation in recentConversations"
:key="conversation.conversationId"
class="flex justify-between items-start p-3 mb-2 w-full rounded-md cursor-pointer group hover:bg-gray-100 dark:hover:bg-gray-700"
>
<div @click="handleConversationClick(conversation.conversationId)" class="flex flex-1 items-start space-x-2 w-[85%] overflow-hidden">
<div class="overflow-hidden w-full">
<p class="text-sm font-medium text-gray-900 truncate whitespace-nowrap dark:text-white">
{{ conversation.title }}
</p>
<p v-if="conversation.time" class="text-xs text-gray-500 truncate dark:text-gray-400">{{ conversation.time }}</p>
</div>
</div>
<button
@click.stop="deleteChat(conversation.conversationId)"
class="hidden ml-2 text-gray-400 group-hover:block hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { ChatMessage } from '../ts/index';
import { useRouter } from 'vue-router';
import { Local } from '/@/utils/storage';
import { useUserInfo } from '/@/stores/userInfo';
import { generateConversationKey } from '../ts/message';
import { ElMessageBox } from 'element-plus';
// 定义对话接口
interface Conversation {
conversationId: string; // 对话ID
title: string; // 对话标题
time?: string; // 对话时间
datasetId: string; // 知识库ID
}
// 组件属性定义
const props = defineProps({
knowledgeId: {
type: String,
required: true, // 必需的知识库ID
},
});
// 定义组件事件
const emit = defineEmits(['conversation-selected']);
const router = useRouter();
// 最近对话列表
const recentConversations = ref<Conversation[]>([]);
// 从本地存储加载最近对话
const loadRecentConversations = () => {
// 基于知识库ID和用户ID生成存储键前缀
const baseKey = `chat-${props.knowledgeId}-${useUserInfo().userInfos.user.userId}`;
// 查找所有匹配模式的键
const allKeys = Object.keys(localStorage).filter((key) => key.includes(baseKey));
if (allKeys.length > 0) {
// 获取对话数据
const conversations: Conversation[] = [];
for (const key of allKeys) {
const storedData = Local.get(key);
if (storedData) {
try {
const parsedData = JSON.parse(storedData);
if (parsedData && Array.isArray(parsedData) && parsedData.length > 0) {
// 找到第一条用户消息作为标题
const userMessage = parsedData.find((m: ChatMessage) => m.role === 'user');
if (userMessage) {
conversations.push({
conversationId: key,
title: userMessage.content,
time: userMessage.time,
datasetId: props.knowledgeId,
});
}
}
} catch (e) {
// 忽略解析错误
}
}
}
// 按时间戳排序(最新的在前)
conversations.sort((a: Conversation, b: Conversation) => {
const timeA = a.time ? new Date(a.time).getTime() : 0;
const timeB = b.time ? new Date(b.time).getTime() : 0;
return timeB - timeA;
});
// 只保留10个最近的对话删除其余的
if (conversations.length > 10) {
const conversationsToKeep = conversations.slice(0, 10);
const conversationsToDelete = conversations.slice(10);
// 从本地存储中删除较旧的对话
conversationsToDelete.forEach((conversation: Conversation) => {
Local.remove(conversation.conversationId);
});
recentConversations.value = conversationsToKeep;
} else {
recentConversations.value = conversations;
}
} else {
recentConversations.value = [];
}
};
// 处理对话点击事件
const handleConversationClick = (conversationId: string) => {
emit('conversation-selected', conversationId);
};
// 删除特定聊天记录
const deleteChat = async (conversationId: string) => {
if (conversationId) {
try {
// 确认删除提示
await ElMessageBox.confirm('确定要删除该聊天记录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
// 从本地存储中移除
Local.remove(conversationId);
// 从显示列表中移除
recentConversations.value = recentConversations.value.filter((conv) => conv.conversationId !== conversationId);
// 如果这是当前选中的对话,创建一个新的
emit('conversation-selected', '');
} catch {
// 用户取消操作
}
}
};
// 清空所有聊天记录
const clearAllChats = async () => {
try {
// 确认清空提示
await ElMessageBox.confirm('确定要清空所有聊天记录吗?此操作不可撤销。', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
// 获取此知识库的基本键模式
const baseKey = `chat-${props.knowledgeId}-${useUserInfo().userInfos.user.userId}`;
// 查找所有匹配的键
const allKeys = Object.keys(localStorage).filter((key) => key.includes(baseKey));
// 逐个移除
for (const key of allKeys) {
Local.remove(key);
}
// 清空显示列表
recentConversations.value = [];
// 创建新对话
createNewChat();
} catch {
// 用户取消操作
}
};
// 创建新聊天,同时保留必要的查询参数
const createNewChat = () => {
// 获取当前路由
const currentRoute = router.currentRoute.value;
// 从路由查询参数中获取mcpId和dataId
const mcpId = currentRoute.query.mcpId as string || '';
const dataId = currentRoute.query.dataId as string || '';
// 格式化当前日期和时间作为标题
const now = new Date();
const dateStr = now.toLocaleDateString();
const timeStr = now.toLocaleTimeString();
const chatTitle = `新对话 (${dateStr} ${timeStr})`;
const conversationId = generateConversationKey(props.knowledgeId, false, mcpId, dataId);
// 添加到最近对话列表
const newConversation: Conversation = {
conversationId: conversationId,
title: chatTitle,
time: now.toLocaleString(),
datasetId: props.knowledgeId,
};
recentConversations.value = [newConversation, ...recentConversations.value];
// 发送对话ID
emit('conversation-selected', conversationId);
// 构建查询参数
const query: Record<string, any> = {
datasetId: props.knowledgeId,
_t: Date.now(), // 时间戳防止缓存
};
// 保留原有的dataId参数如果存在
if (currentRoute.query.dataId) {
query.dataId = currentRoute.query.dataId;
}
// 保留原有的mcpId参数如果存在
if (currentRoute.query.mcpId) {
query.mcpId = currentRoute.query.mcpId;
}
// 保留原有的datasetName参数如果存在
if (currentRoute.query.datasetName) {
query.datasetName = currentRoute.query.datasetName;
}
};
// 组件挂载时初始加载
onMounted(() => {
loadRecentConversations();
});
// 监听知识库ID变化以重新加载消息
watch(() => props.knowledgeId, loadRecentConversations);
</script>

View File

@@ -0,0 +1,724 @@
<template>
<!-- 聊天输入框容器固定在底部 -->
<div class="sticky bottom-4 px-2 w-full">
<!-- 上传文件/图片预览区域 -->
<div
v-if="(uploadedFiles.length > 0 || uploadedImages.length > 0) && isFileUploadAllowed"
class="flex flex-wrap gap-2 px-4 mx-auto mb-2 w-full md:w-4/5"
>
<!-- 文件预览 -->
<div
v-for="(file, index) in uploadedFiles"
:key="'file-' + index"
class="flex items-center px-3 py-1.5 bg-gray-100 rounded-full shadow-sm dark:bg-gray-700"
>
<!-- PDF文件图标 -->
<svg
v-if="getFileExtension(file.name) === 'pdf'"
xmlns="http://www.w3.org/2000/svg"
class="mr-2 w-4 h-4 text-red-500 dark:text-red-400"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4" />
<path d="M5 18h1.5a1.5 1.5 0 0 0 0 -3h-1.5v6" />
<path d="M17 18h2" />
<path d="M20 15h-3v6" />
<path d="M11 15v6h1a2 2 0 0 0 2 -2v-2a2 2 0 0 0 -2 -2h-1z" />
</svg>
<!-- Word文档图标 -->
<svg
v-else-if="['doc', 'docx'].includes(getFileExtension(file.name))"
xmlns="http://www.w3.org/2000/svg"
class="mr-2 w-4 h-4 text-blue-600 dark:text-blue-400"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" />
<path d="M9 9l1 0" />
<path d="M9 13l6 0" />
<path d="M9 17l6 0" />
</svg>
<!-- Excel文件图标 -->
<svg
v-else-if="['xls', 'xlsx'].includes(getFileExtension(file.name))"
xmlns="http://www.w3.org/2000/svg"
class="mr-2 w-4 h-4 text-green-600 dark:text-green-400"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" />
<path d="M8 11h8v7h-8z" />
<path d="M8 15h8" />
<path d="M11 11v7" />
</svg>
<!-- PowerPoint图标 -->
<svg
v-else-if="['ppt', 'pptx'].includes(getFileExtension(file.name))"
xmlns="http://www.w3.org/2000/svg"
class="mr-2 w-4 h-4 text-orange-500 dark:text-orange-400"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4" />
<path d="M8 12h8" />
<path d="M8 16h8" />
<path d="M8 20h8" />
</svg>
<!-- 文本文件图标 -->
<svg
v-else-if="getFileExtension(file.name) === 'txt'"
xmlns="http://www.w3.org/2000/svg"
class="mr-2 w-4 h-4 text-gray-600 dark:text-gray-400"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" />
<path d="M9 9h1" />
<path d="M9 13h6" />
<path d="M9 17h6" />
</svg>
<!-- 默认文件图标 -->
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="mr-2 w-4 h-4 text-gray-500 dark:text-gray-400"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" />
</svg>
<span class="text-sm truncate max-w-[100px] text-gray-700 dark:text-gray-200">{{ file.originalFileName || file.name }}</span>
<button @click="removeFile(index)" class="ml-2 text-gray-400 hover:text-red-500 dark:hover:text-red-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</button>
</div>
<!-- 图片预览 -->
<div
v-for="(image, index) in uploadedImages"
:key="'img-' + index"
class="flex items-center px-3 py-1.5 bg-gray-100 rounded-full shadow-sm dark:bg-gray-700"
>
<img :src="baseURL + image.url" class="object-cover mr-2 w-4 h-4 rounded" />
<span class="text-sm truncate max-w-[100px] text-gray-700 dark:text-gray-200">{{ image.name }}</span>
<button @click="removeImage(index)" class="ml-2 text-gray-400 hover:text-red-500 dark:hover:text-red-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</button>
</div>
<!-- 文件解析状态 -->
<div v-if="isParsingFile || parseMessage" class="flex items-center mt-1 w-full text-sm text-blue-600 dark:text-blue-400">
<svg v-if="isParsingFile" class="mr-2 w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>{{ parseMessage || '文件解析中,请稍候...' }}</span>
</div>
</div>
<!-- 主要输入区域容器 -->
<div class="flex justify-center items-center w-full">
<label for="prompt" class="sr-only">Enter your prompt</label>
<!-- 文本输入区域 - 改进设计的输入框 -->
<div class="flex relative items-center w-full md:w-4/5">
<input
v-model="content"
id="chat"
type="text"
@keydown.enter.prevent="onSendMessage"
:placeholder="isParsingFile ? '文件解析中,请稍候...' : placeholder"
:disabled="!isFinish || isParsingFile"
class="px-4 py-3 pr-24 w-full h-14 text-base text-gray-900 bg-white rounded-full border shadow-sm transition-all duration-200 border-slate-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 placeholder-slate-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/25 focus:outline-none hover:border-slate-300 dark:hover:border-gray-600"
/>
<!-- 功能按钮区域 - 改进了按钮的样式 -->
<div class="flex absolute right-3 items-center space-x-2">
<!-- 文件上传按钮 -->
<button
v-if="isFileUploadAllowed"
:disabled="!isFinish"
class="p-1.5 text-gray-500 rounded-full transition-colors duration-200 hover:text-blue-600 hover:bg-blue-50 dark:text-gray-400 dark:hover:text-blue-400 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
@click="openFileUploadDialog"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5"
aria-hidden="true"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"></path>
<path d="M12 11v6"></path>
<path d="M9.5 13.5l2.5 -2.5l2.5 2.5"></path>
</svg>
</button>
<!-- 图片上传按钮 -->
<button
v-if="isFileUploadAllowed"
:disabled="!isFinish"
class="p-1.5 text-gray-500 rounded-full transition-colors duration-200 hover:text-blue-600 hover:bg-blue-50 dark:text-gray-400 dark:hover:text-blue-400 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
@click="openImageUploadDialog"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5"
aria-hidden="true"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M15 8h.01"></path>
<path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z"></path>
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"></path>
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"></path>
</svg>
</button>
<!-- MCP选择按钮 -->
<button
v-if="isMcpSelectionAllowed"
:disabled="!isFinish"
class="p-1.5 text-gray-500 rounded-full transition-colors duration-200 hover:text-blue-600 hover:bg-blue-50 dark:text-gray-400 dark:hover:text-blue-400 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
@click="onOpenMcp"
>
<svg
fill="currentColor"
fill-rule="evenodd"
class="w-5 h-5"
style="flex: none; line-height: 1"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<title>ModelContextProtocol</title>
<path
d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"
></path>
<path
d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"
></path>
</svg>
</button>
<!-- 添加功能按钮 - 仅在特定知识库显示 -->
<button
v-if="isAdvancedFeaturesAllowed"
:disabled="!isFinish"
class="p-1.5 text-gray-500 rounded-full transition-colors duration-200 hover:text-blue-600 hover:bg-blue-50 dark:text-gray-400 dark:hover:text-blue-400 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
@click="onOpenDialog"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5"
aria-hidden="true"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 5l0 14"></path>
<path d="M5 12l14 0"></path>
</svg>
</button>
<!-- 语音输入按钮 -->
<button
:disabled="!isFinish"
class="p-1.5 text-gray-500 rounded-full transition-colors duration-200 hover:text-blue-600 hover:bg-blue-50 dark:text-gray-400 dark:hover:text-blue-400 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
@click="onOpenAudio"
>
<svg
t="1723604425873"
class="w-5 h-5"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4335"
width="256"
height="256"
>
<!-- 语音图标SVG路径 -->
<path
d="M483.363 517.9h-53.357v53.348h53.357V517.9z m-160.051 0v53.348h53.348V517.9h-53.348z m213.398 53.348h53.348V517.9H536.71v53.348z m160.051 0V517.9h-53.347v53.348h53.347z m-266.755 53.365v53.348h53.357v-53.348h-53.357z m-106.694 0l19.802 53.348h33.545v-53.348h-53.347z m213.398 53.348h53.348v-53.348H536.71v53.348z m-106.704 69.748l53.357 10.273v-26.674h-53.357v16.401z m106.704 5.514l53.348-8.576v-13.338H536.71v21.914z m106.704-75.262h38.922l14.425-53.348h-53.347v53.348zM536.71 464.553h-53.347V517.9h53.347v-53.347z m-106.704 0h-53.347V517.9h53.347v-53.347zM590.058 517.9h53.356v-53.347h-53.356V517.9zM483.363 624.613h53.347v-53.365h-53.347v53.365z m-53.357-53.365h-53.347v53.365h53.347v-53.365z m160.052 53.365h53.356v-53.365h-53.356v53.365z m-106.695 53.348v53.348h53.347v-53.348h-53.347z m-106.704 0v36.746l53.347 16.602v-53.348h-53.347z m213.399 53.348l53.356-17.449v-35.898h-53.356v53.347z m106.703-160.061v53.365l17.414-35.896v-17.469h-17.414zM536.71 464.553h53.348v-53.348H536.71v53.348z m160.051 0v-53.348h-53.347v53.348h53.347z m-213.398-53.347h-53.357v53.348h53.357v-53.348z m-160.051 0v53.348h53.348v-53.348h-53.348z m106.694-53.348h-53.347v53.348h53.347v-53.348z m106.704 0h-53.347v53.348h53.347v-53.348z m53.348 53.348h53.356v-53.348h-53.356v53.348z m-53.348-53.348h53.348v-53.347H536.71v53.347z m160.051 0v-53.347h-53.347v53.347h53.347z m-213.398-53.347h-53.357v53.347h53.357v-53.347z m-160.051 0v53.347h53.348v-53.347h-53.348z m106.694-53.347h-53.347v53.347h53.347V144.45z m106.704 53.347V144.45h-53.347v53.347h53.347z m53.348 0h53.356V144.45h-53.356v53.347z m0-53.347V91.103H536.71v53.347h53.348z m106.703 0l-53.347-53.347v53.347h53.347z m-213.398 0V91.103h-53.357v53.347h53.357zM363.58 104.846l-40.269 39.604h53.348V91.103l-13.079 13.743z m173.13-67.091h-53.347v53.348h53.347V37.755zM430.006 64.429l-26.674 13.337-26.673 13.337h53.347V64.429zM624.092 78.08l-34.034-13.65v26.674h53.356L624.092 78.08z"
fill="currentColor"
p-id="4336"
></path>
<path
d="M847.257 554.738c0-16.934-5.533-27.965-15.551-27.965-10.938 0-15.568 11.031-15.568 27.965 0 162.92-169.403 303.777-306.166 303.777-155.836 0-305.77-152-305.77-304.184 0-16.934-9.233-21.527-15.098-21.527-6.42 0-16.427 5-16.427 21.934 0 175.906 151.252 320.232 323.128 335.746v72.512h-169.81c-16.952 0-30.667 5.609-30.667 18.281 0 11.824 13.715 15.365 30.667 15.365h367.953c16.953 0 30.667-2.951 30.667-15.346 0-13.283-13.714-18.301-30.667-18.301H526.499v-72.512c171.877-15.512 320.758-159.838 320.758-335.745zM509.972 769.383c118.547 0 214.644-96.125 214.644-214.645V248.12c0-118.537-96.097-214.644-214.644-214.644-118.546 0-214.644 96.106-214.644 214.644v306.619c0 118.519 96.097 214.644 214.644 214.644zM328.163 248.12c0-101.197 75.852-183.248 181.809-183.248 97.397 3.247 180.73 75.262 183.387 183.248v306.619c0 97.971-73.104 185.332-183.387 185.332-110.394 0-181.809-85.408-181.809-185.332V248.12z"
fill="currentColor"
p-id="4337"
></path>
</svg>
</button>
<!-- 联网搜索切换按钮 - 仅在特定知识库显示 -->
<button
v-if="isWebSearchAllowed"
:disabled="!isFinish"
class="p-1.5 rounded-full transition-colors duration-200 tooltip hover:text-blue-600 hover:bg-blue-50 dark:text-gray-400 dark:hover:text-blue-400 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
:class="{ 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-gray-700': isReasoningMode, 'text-gray-500': !isReasoningMode }"
data-tip="联网搜索"
type="button"
@click="toggleReasoningMode"
>
<svg
t="1739106263731"
class="w-5 h-5"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4278"
width="256"
height="256"
>
<!-- 联网搜索图标SVG路径 -->
<path
d="M518.559767 990.705821a481.102936 481.102936 0 0 0 66.717634-5.439807C709.113009 877.10985 789.110171 705.435939 789.110171 512.0028s-79.997163-365.10705-203.83277-473.263214a481.102936 481.102936 0 0 0-66.717634-5.439807c141.914966 95.996595 237.431579 274.550262 237.431579 478.703021s-95.516612 382.546432-237.431579 478.703021zM498.400482 990.705821a483.182862 483.182862 0 0 1-66.397645-5.439807C307.847241 877.10985 227.850078 705.435939 227.850078 512.0028S307.847241 146.89575 432.002837 38.739586a483.182862 483.182862 0 0 1 66.717634-5.439807C356.485516 129.456368 260.968904 307.850041 260.968904 512.0028s95.516612 382.546432 237.431578 478.703021z"
fill="currentColor"
p-id="4279"
></path>
<path
d="M512 805.272398a509.421931 509.421931 0 0 0-287.989785 88.956845c9.439665 7.199745 19.359313 13.919506 29.278961 20.479273a478.223038 478.223038 0 0 1 518.381614 0c9.919648-6.559767 19.839296-13.279529 29.278961-20.479273A509.421931 509.421931 0 0 0 512 805.272398zM512 218.573207A509.421931 509.421931 0 0 1 224.010215 129.616363c9.439665-7.199745 19.359313-13.919506 29.278961-20.31928A476.783089 476.783089 0 0 0 512 185.454382a476.783089 476.783089 0 0 0 259.190807-76.157299c9.919648 6.399773 19.839296 13.119535 29.278961 20.31928A509.421931 509.421931 0 0 1 512 218.573207zM6.737921 495.36339h984.125094v33.118825H6.737921z"
fill="currentColor"
p-id="4280"
></path>
<path
d="M970.863725 285.130847a14.559484 14.559484 0 0 0-1.119961-2.239921v-0.799971l-1.279954-1.599944a15.999433 15.999433 0 0 0-26.879047 18.879331A478.863015 478.863015 0 0 1 512 990.865815c-175.993758 0-335.988083-104.956277-418.865143-246.871244a15.999433 15.999433 0 0 0-15.999433-11.039608 15.999433 15.999433 0 0 0-15.999432 15.999433 17.599376 17.599376 0 0 0 1.599943 7.199744A504.142119 504.142119 0 0 0 512 1023.984641a511.981841 511.981841 0 0 0 458.863725-738.853794zM63.055923 678.876881H64.015889A447.984111 447.984111 0 0 1 33.136985 512.0028 478.703021 478.703021 0 0 1 879.986948 205.933656a15.999433 15.999433 0 0 0 26.239069-20.159285v-1.919932A504.782096 504.782096 0 0 0 512 0.020959 511.981841 511.981841 0 0 0 0.018159 512.0028a555.98028 555.98028 0 0 0 31.998865 177.91369 16.799404 16.799404 0 0 0 33.118826-3.839864 15.999433 15.999433 0 0 0-2.079927-7.199745z"
fill="currentColor"
p-id="4281"
></path>
<path
d="M927.985246 240.332436m-16.63941 0a16.63941 16.63941 0 1 0 33.278819 0 16.63941 16.63941 0 1 0-33.278819 0Z"
fill="currentColor"
p-id="4282"
></path>
<path d="M489.120811 11.220562h35.678735v993.56476h-35.678735z" fill="currentColor" p-id="4283"></path>
</svg>
</button>
</div>
</div>
<!-- 发送按钮 -->
<button
type="submit"
:disabled="!isFinish"
@click="onSendMessage"
class="p-2 ml-2 text-gray-500 bg-white rounded-full shadow-sm transition-all duration-200 hover:text-blue-600 hover:bg-blue-50 dark:bg-gray-800 dark:text-gray-400 dark:hover:text-blue-400 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
aria-hidden="true"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M10 14l11 -11"></path>
<path d="M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5"></path>
</svg>
</button>
</div>
</div>
<!-- 文件上传对话框 -->
<el-dialog v-model="fileUploadDialogVisible" width="500px">
<Upload
ref="fileUploadRef"
v-model="fileUrls"
:file-size="1"
:limit="1"
:file-type="['doc', 'docx', 'txt', 'xls', 'xlsx', 'ppt', 'pptx']"
@change="handleFileUploadChange"
/>
<template #footer>
<div class="flex gap-2 justify-end">
<el-button @click="fileUploadDialogVisible = false">{{ $t('common.cancelButtonText') }}</el-button>
<el-button type="primary" @click="confirmFileUpload">{{ $t('common.confirmButtonText') }}</el-button>
</div>
</template>
</el-dialog>
<!-- 图片上传对话框 -->
<el-dialog v-model="imageUploadDialogVisible" width="500px">
<Upload
ref="imageUploadRef"
v-model="imageUrls"
:file-size="1"
:limit="1"
:file-type="['png', 'jpg', 'jpeg']"
@change="handleImageUploadChange"
/>
<template #footer>
<div class="flex gap-2 justify-end">
<el-button @click="imageUploadDialogVisible = false">{{ $t('common.cancelButtonText') }}</el-button>
<el-button type="primary" @click="confirmImageUpload">{{ $t('common.confirmButtonText') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { Dataset, FileBase64 } from '../ts/index';
import Upload from '/@/components/Upload/index.vue';
import { useMessage } from '/@/hooks/message';
import { embedDocument } from '/@/api/knowledge/aiDocument';
import other from '/@/utils/other';
// 组件属性定义
const props = defineProps({
selectedKnowledge: {
type: Object as () => Dataset,
required: true, // 当前选择的知识库,必填项
},
placeholder: {
type: String,
default: 'Your message...', // 输入框占位符,默认文本
},
messageContent: {
type: String,
default: '', // 消息内容,默认为空
},
isReasoningMode: {
type: Boolean,
default: false, // 推理模式状态,默认关闭
},
isFinish: {
type: Boolean,
default: true, // 是否完成生成默认为true允许发送
},
loading: {
type: Boolean,
default: false, // 加载状态,防止在生成回答时发送新消息
},
});
// 定义组件事件
const emit = defineEmits([
'sendMessage',
'openDialog',
'openAudio',
'openMcp',
'toggleReasoningMode',
'update:messageContent',
'update:isReasoningMode',
'fileUploaded',
'imageUploaded',
]);
// 消息内容的响应式引用
const content = ref(props.messageContent);
// 文件上传相关引用和状态
const uploadedFiles = ref<{ name: string; url: string; originalFileName: string }[]>([]);
const uploadedImages = ref<{ name: string; url: string }[]>([]);
// 文件解析状态
const isParsingFile = ref(false);
const parseMessage = ref('');
// 获取 baseURL 从环境变量
const baseURL = import.meta.env.VITE_API_URL || '';
// 上传组件引用
const fileUploadRef = ref();
const imageUploadRef = ref();
// 对话框控制
const fileUploadDialogVisible = ref(false);
const imageUploadDialogVisible = ref(false);
// 上传后的URL
const fileUrls = ref('');
const imageUrls = ref('');
// 消息提示
const { error, success } = useMessage();
// 知识库ID相关计算属性
const isFileUploadAllowed = computed(() => ['0'].includes(props.selectedKnowledge?.id));
const isAdvancedFeaturesAllowed = computed(() => ['0', '-1', '-2', '-6'].includes(props.selectedKnowledge?.id));
const isWebSearchAllowed = computed(() => props.selectedKnowledge?.id === '-7');
const isMcpSelectionAllowed = computed(() => props.selectedKnowledge?.id === '-8');
// 与父组件的双向绑定 - 监听props变化更新本地状态
watch(
() => props.messageContent,
(newVal) => {
content.value = newVal;
}
);
// 监听本地状态变化更新父组件
watch(
() => content.value,
(newVal) => {
emit('update:messageContent', newVal);
}
);
// 打开文件上传对话框
const openFileUploadDialog = () => {
if (!props.isFinish) return;
fileUploadDialogVisible.value = true;
fileUrls.value = '';
};
// 打开图片上传对话框
const openImageUploadDialog = () => {
if (!props.isFinish) return;
imageUploadDialogVisible.value = true;
imageUrls.value = '';
};
// 处理文件上传变化
const handleFileUploadChange = (url: string) => {
if (!url) return;
fileUrls.value = url;
};
// 处理图片上传变化
const handleImageUploadChange = (url: string) => {
if (!url) return;
imageUrls.value = url;
};
// 确认文件上传
const confirmFileUpload = async () => {
if (!fileUrls.value) {
error('请先上传文件');
return;
}
const urls = fileUrls.value.split(',');
uploadedFiles.value = urls.map((url) => {
return {
name: other.getQueryString(url, 'fileName'),
url: url,
originalFileName: other.getQueryString(url, 'originalFileName'),
};
});
// 发送文件URL给父组件
if (uploadedFiles.value.length > 0) {
const fileData: FileBase64 = {
name: uploadedFiles.value[0].name,
type: getFileExtension(uploadedFiles.value[0].name),
size: 0,
url: uploadedFiles.value[0].url,
};
fileUploadDialogVisible.value = false;
// 设置解析状态
isParsingFile.value = true;
parseMessage.value = '文件解析中,请稍候...';
try {
// 调用文件解析API
const response = await embedDocument({
url: uploadedFiles.value[0].url,
name: uploadedFiles.value[0].name,
});
if (response && response.ok) {
success('文件解析成功');
parseMessage.value = '';
isParsingFile.value = false;
emit('fileUploaded', fileData);
} else {
parseMessage.value = '文件解析失败,请重试';
isParsingFile.value = false;
}
} catch (err) {
parseMessage.value = '文件解析失败,请重试';
isParsingFile.value = false;
}
}
};
// 确认图片上传
const confirmImageUpload = () => {
if (!imageUrls.value) {
error('请先上传图片');
return;
}
const urls = imageUrls.value.split(',');
uploadedImages.value = urls.map((url) => {
// 使用URL对象和URLSearchParams来解析URL参数
const urlObj = new URL(url, window.location.origin);
const params = new URLSearchParams(urlObj.search);
const fileName = params.get('fileName') || urlObj.pathname.split('/').pop() || 'unknown.image';
return {
name: fileName,
file: new File([], fileName),
url: url,
};
});
// 发送图片URL给父组件
if (uploadedImages.value.length > 0) {
const imageData: FileBase64 = {
name: uploadedImages.value[0].name,
type: getFileExtension(uploadedImages.value[0].name),
size: 0,
url: uploadedImages.value[0].url,
};
emit('imageUploaded', imageData);
}
imageUploadDialogVisible.value = false;
};
// 移除上传的文件
const removeFile = (index: number) => {
uploadedFiles.value.splice(index, 1);
parseMessage.value = '';
isParsingFile.value = false;
};
// 移除上传的图片
const removeImage = (index: number) => {
uploadedImages.value.splice(index, 1);
};
// 发送消息方法
const onSendMessage = () => {
if ((!content.value.length && uploadedFiles.value.length === 0 && uploadedImages.value.length === 0) || !props.isFinish || isParsingFile.value)
return; // 如果消息为空且没有上传文件或未完成生成或文件正在解析则不发送
// 发送消息(不需要再传递文件和图片,因为已在上传时单独发送)
emit('sendMessage');
};
// 打开对话框方法 - 用于添加功能
const onOpenDialog = () => {
if (!props.isFinish) return; // 如果未完成生成则不执行
emit('openDialog');
};
// 打开语音输入方法
const onOpenAudio = () => {
if (!props.isFinish) return; // 如果未完成生成则不执行
emit('openAudio');
};
// 切换推理模式方法 - 用于联网搜索
const toggleReasoningMode = () => {
if (!props.isFinish) return; // 如果未完成生成则不执行
emit('update:isReasoningMode', !props.isReasoningMode);
};
// 打开MCP选择方法
const onOpenMcp = () => {
if (!props.isFinish) return; // 如果未完成生成则不执行
emit('openMcp');
};
// 辅助函数 - 获取文件扩展名
const getFileExtension = (fileName: string): string => {
const parts = fileName.split('.');
if (parts.length > 1) {
return parts[parts.length - 1].toLowerCase();
}
return '';
};
</script>

View File

@@ -0,0 +1,448 @@
<template>
<div class="flex flex-col py-3 rounded-lg group">
<template v-if="(message.role as string) === 'assistant'">
<!-- Assistant message -->
<div class="relative chat chat-start" v-if="message.content || message.reasoning_content">
<div class="chat-image avatar">
<div v-if="selectedKnowledge?.avatarUrl?.includes('svg')" v-html="selectedKnowledge?.avatarUrl"></div>
<div class="w-[40px] h-[40px] rounded-full overflow-hidden" v-else>
<img
:src="selectedKnowledge?.avatarUrl?.includes('http') ? selectedKnowledge?.avatarUrl : baseURL + selectedKnowledge?.avatarUrl"
class="object-cover w-full h-full"
/>
</div>
</div>
<div class="flex items-center mb-1 ml-6 space-x-2 chat-header">
<span class="font-medium text-gray-800 dark:text-gray-200">AI 助手</span>
<time class="text-xs opacity-70">{{ message.time }}</time>
<div class="flex items-center space-x-1">
<el-button
@click="handleCopyText(message.content)"
class="!p-1 h-6"
text
icon="CopyDocument"
v-if="message.content"
type="primary"
></el-button>
<audio-player
ref="audioPlayerRef"
:text="message.content"
v-if="message === messageList[messageList.length - 1] && message.content && messageList.length > 1"
/>
<el-button
@click="handleRegenerate()"
class="!p-1 h-6"
text
icon="Refresh"
v-if="message === messageList[messageList.length - 1] && message.content && messageList.length > 1"
type="primary"
></el-button>
</div>
</div>
<div class="max-w-3xl bg-white rounded-lg dark:bg-gray-800 dark:border-gray-700">
<div class="flex flex-col gap-3 p-4" :class="{ 'md:flex-row': message.content && message.chartId && isWideScreen }">
<!-- Reasoning content collapsible panel -->
<div
v-if="message.reasoning_content"
:class="[
'bg-gray-50 rounded-lg border border-gray-200 collapse collapse-arrow dark:bg-gray-800 dark:border-gray-700',
collapseStates[message.time || ''] === false ? 'collapse-close' : 'collapse-open',
]"
>
<input type="checkbox" class="peer" @change="collapseStates[message.time || ''] = !collapseStates[message.time || '']" />
<div
class="text-sm font-medium text-gray-700 collapse-title dark:text-gray-300 peer-checked:bg-gray-100 dark:peer-checked:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700/50"
>
<div class="flex gap-2 items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-gray-500 dark:text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
已深度思考用时 {{ message.thinking_time || '0' }}
</div>
</div>
<div class="collapse-content bg-gray-50/50 dark:bg-gray-800/50 peer-checked:bg-gray-100 dark:peer-checked:bg-gray-700/50">
<div class="pt-4 text-gray-600 whitespace-pre-wrap dark:text-gray-300">
{{ message.reasoning_content }}
</div>
</div>
</div>
<!-- Tool info collapsible panel -->
<div
v-if="message.toolInfo"
:class="[
'bg-gray-50 rounded-lg border border-gray-200 collapse collapse-arrow dark:bg-gray-800 dark:border-gray-700',
collapseStates['tool_' + index] === true ? 'collapse-open' : 'collapse-close',
]"
>
<input
type="checkbox"
class="peer"
@change="collapseStates['tool_' + index] = !collapseStates['tool_' + index]"
/>
<div
class="text-sm font-medium text-gray-700 collapse-title dark:text-gray-300 peer-checked:bg-gray-100 dark:peer-checked:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700/50"
>
<div class="flex gap-2 items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-gray-500 dark:text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
工具调用{{ message.toolInfo.name }}
</div>
</div>
<div class="collapse-content bg-gray-50/50 dark:bg-gray-800/50 peer-checked:bg-gray-100 dark:peer-checked:bg-gray-700/50">
<div class="pt-4">
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">工具名称</div>
<div class="mb-4 px-3 py-2 bg-white rounded border text-gray-800 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200">
{{ message.toolInfo.name }}
</div>
<div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">调用参数</div>
<div class="px-3 py-2 bg-white rounded border text-gray-800 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200">
<pre class="whitespace-pre-wrap text-sm">{{ getFormattedToolParams(message.toolInfo.params) }}</pre>
</div>
</div>
</div>
</div>
<!-- Main content -->
<div
ref="contentContainer"
class="relative text-gray-800 dark:text-gray-200"
v-if="message.content"
:class="{ 'md:flex-1 md:max-w-[60%]': message.chartId && isWideScreen }"
>
<MdRenderer v-if="selectedKnowledge.id !== '-4'" :source="message.content" class="w-full" />
<MindMap v-else :text="message.content" :id="message.time || ''" />
<router-link
v-if="message.path"
:to="message.path"
class="inline-flex absolute top-1 right-1 items-center text-sm text-blue-600 transition-colors duration-200 shrink-0 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
一键跳转
</router-link>
</div>
<!-- Chart display for chartId messages -->
<div
v-if="message.chartId"
class="overflow-hidden relative flex-1 mt-6 w-full bg-white rounded-lg border border-gray-200 shadow-sm dark:bg-gray-800 dark:border-gray-700 chart-container"
:class="{ 'md:mt-0 md:min-w-[500px] md:max-w-[40%]': message.content && isWideScreen }"
style="min-height: 300px; height: 50vh; max-height: 500px"
>
<!-- 图表标题栏 -->
<div class="flex justify-between items-center p-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<span class="font-medium text-gray-700 dark:text-gray-300">数据可视化</span>
</div>
</div>
<!-- 图表内容 -->
<div class="p-3 h-[calc(100%-48px)]">
<v-chart v-if="chartOption" class="w-[500px] h-full" :option="chartOption" autoresize />
<!-- 图表加载状态 -->
<div v-else class="flex justify-center items-center h-full">
<div class="flex flex-col items-center">
<span class="mb-2 text-gray-600 dark:text-gray-300">图表加载中...</span>
<div class="flex space-x-2">
<span class="text-blue-500 loading loading-spinner loading-md"></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="flex gap-2 items-center ml-6 chat-footer"
v-if="
(message.extLinks && message.extLinks.length > 0) ||
(message === messageList[messageList.length - 1] && messageList.length > 1 && !isFinish)
"
>
<!-- Stop button -->
<el-button
v-if="message === messageList[messageList.length - 1] && messageList.length > 1 && !isFinish"
@click="handleStopGenerate()"
text
icon="VideoPause"
type="primary"
class="!bg-white dark:!bg-gray-700 !shadow-md !rounded-full !p-2"
></el-button>
<!-- Reference materials -->
<div v-if="message.extLinks && message.extLinks.length > 0" class="flex-1 px-3 rounded-md dark:bg-gray-700/40">
<div class="flex flex-wrap gap-2 items-center text-xs">
<span class="font-medium text-gray-600 dark:text-gray-300">参考资料</span>
<div class="flex flex-wrap gap-1.5">
<el-tag
v-for="(link, linkIndex) in message.extLinks"
:key="linkIndex"
size="small"
effect="plain"
class="!border-0 !bg-gray-100 dark:!bg-gray-600 !text-gray-700 dark:!text-gray-300"
>
{{ link.name }}
</el-tag>
</div>
<!-- Rating -->
<div v-if="message.extLinks[0].distance" class="flex items-center ml-auto space-x-1">
<div class="tooltip tooltip-left" :data-tip="`相关度: ${Number(message.extLinks[0].distance).toFixed(2)}`">
<div class="rating rating-xs">
<input
v-for="n in 5"
:key="n"
type="radio"
:name="'rating' + index"
class="mask mask-star-2 bg-blue-400/80 dark:bg-blue-500/80"
:checked="Math.round(message.extLinks[0].distance * 5) === n"
disabled
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Loading indicator for LLM responses -->
<div class="flex" v-if="!message.content && !message.reasoning_content">
<div class="flex justify-center items-center mt-2 ml-12 text-center">
<span class="mx-0.5 loading loading-ball loading-xs dark:bg-blue-500"></span>
<span class="mx-0.5 loading loading-ball loading-sm dark:bg-blue-500"></span>
<span class="mx-0.5 loading loading-ball loading-md dark:bg-blue-500"></span>
<span class="mx-0.5 loading loading-ball loading-lg dark:bg-blue-500"></span>
</div>
</div>
</template>
<template v-else-if="(message.role as string) === 'user'">
<!-- User message -->
<div class="chat chat-end">
<div class="chat-image avatar">
<div class="w-[40px] h-[40px] rounded-full overflow-hidden">
<img
alt="User avatar"
:src="roleAlias[message.role]?.includes('http') ? roleAlias[message.role] : baseURL + roleAlias[message.role]"
class="object-cover w-full h-full"
/>
</div>
</div>
<div class="flex justify-end items-center mb-1 space-x-2 chat-header">
<time class="text-xs opacity-70">{{ message.time }}</time>
<span class="font-medium text-gray-800 dark:text-gray-200">{{ userInfos.user.username }}</span>
</div>
<div class="max-w-3xl bg-white rounded-lg dark:bg-gray-800 dark:border-gray-700">
<div class="p-4 text-gray-800 dark:text-gray-200">
<MdRenderer v-if="message.content" :source="message.content || ''" class="w-full" />
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import MdRenderer from '/@/components/MdRenderer/MdRenderer.vue';
import MindMap from '/@/components/MindMap/index.vue';
import AudioPlayer from './widgets/audio-player.vue';
import VChart from 'vue-echarts';
import { useIntervalFn, useMediaQuery } from '@vueuse/core';
import type { ChatMessage, Dataset } from '../ts/index';
import { useUserInfo } from '/@/stores/userInfo';
import { getChartData } from '../ts/gpt';
import { use } from 'echarts/core';
import { BarChart } from 'echarts/charts';
import { LineChart } from 'echarts/charts';
import { PieChart } from 'echarts/charts';
import { GridComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
use([GridComponent, BarChart, LineChart, PieChart, CanvasRenderer]);
const stores = useUserInfo();
const { userInfos } = storeToRefs(stores);
const baseURL = import.meta.env.VITE_API_URL || '';
const props = defineProps({
message: {
type: Object as () => ChatMessage,
required: true,
},
index: {
type: Number,
required: true,
},
selectedKnowledge: {
type: Object as () => Dataset,
required: true,
},
messageList: {
type: Array as () => ChatMessage[],
required: true,
},
isFinish: {
type: Boolean,
default: true,
},
roleAlias: {
type: Object,
required: true,
},
collapseStates: {
type: Object,
required: true,
},
});
const emit = defineEmits(['copyText', 'regenerate', 'stopGenerate']);
const audioPlayerRef = ref();
const chartOption = ref(null);
const isPolling = ref(false); // 是否正在轮询中
const isWideScreen = ref(useMediaQuery('(min-width: 1024px)')); // 判断是否为宽屏
const pollAttempts = ref(0); // 轮询尝试次数计数器
// Make content and chart display side by side on wide screens
// 使内容和图表在宽屏上并排显示
const contentContainer = ref<HTMLElement | null>(null);
// Function to fetch chart data
// 获取图表数据的函数
const fetchChartData = async (chartId: string) => {
try {
const { data } = await getChartData(chartId);
if (data) {
chartOption.value = JSON.parse(data);
// 如果成功获取到有效数据,停止轮询
if (chartOption.value) {
isPolling.value = false;
pause();
}
}
} catch (error) {
// 静默处理错误
}
// 限制最多轮询10次
pollAttempts.value++;
if (pollAttempts.value >= 10) {
isPolling.value = false;
pause(); // 达到最大轮询次数后停止
}
};
// Setup interval to poll for chart data
// 设置轮询图表数据的定时器
const { pause, resume } = useIntervalFn(
() => {
if (props.message.chartId) {
fetchChartData(props.message.chartId);
}
},
2000, // Poll every 2 seconds 每2秒轮询一次
{ immediate: false, immediateCallback: false }
);
// Watch for chartId changes to start/stop polling
// 监听chartId变化以开始/停止轮询
watch(
() => props.message.chartId,
(newChartId) => {
if (newChartId && !isPolling.value) {
isPolling.value = true;
pollAttempts.value = 0; // 重置计数器
// Fetch immediately and then start polling
// 立即获取数据然后开始轮询
fetchChartData(newChartId);
resume();
} else if (!newChartId && isPolling.value) {
isPolling.value = false;
pause(); // 当没有chartId时停止轮询
}
},
{ immediate: true } // 立即执行
);
// Stop polling when component is unmounted
// 组件卸载时停止轮询
onUnmounted(() => {
pause();
});
const handleCopyText = (content: string) => {
emit('copyText', content);
};
const handleRegenerate = () => {
emit('regenerate');
};
const handleStopGenerate = () => {
// 停止生成的同时也停止轮询
if (isPolling.value) {
isPolling.value = false;
pause();
}
emit('stopGenerate');
};
// 格式化工具参数,使 JSON 更易读
const getFormattedToolParams = (params: string): string => {
try {
const parsed = JSON.parse(params);
return JSON.stringify(parsed, null, 2);
} catch (error) {
return params; // 如果不是有效的 JSON则返回原始字符串
}
};
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="chat chat-start">
<div class="chat-image avatar">
<div v-if="selectedKnowledge?.avatarUrl?.includes('svg')" v-html="selectedKnowledge?.avatarUrl"></div>
<div class="w-[40px] h-[40px] rounded-full overflow-hidden shadow-sm" v-else>
<img
:src="selectedKnowledge?.avatarUrl?.includes('http') ? selectedKnowledge.avatarUrl : baseURL + selectedKnowledge.avatarUrl"
class="object-cover w-full h-full"
/>
</div>
</div>
<div class="w-full bg-white rounded-lg border-gray-100 dark:border-gray-700 dark:bg-gray-800">
<div class="flex flex-col flex-1 md:mb-auto">
<div v-for="(item, index) in prologueItems" :key="index" class="animate-fadeIn">
<span v-if="item.type === 'md' && item.str" class="flex items-center mb-4 text-xl font-bold text-gray-800 dark:text-gray-200">
<MdRenderer :source="item.str" class="w-full" />
</span>
<ul v-if="item.type === 'question' && item.str" class="flex flex-col gap-3 mb-2 w-full">
<li
class="p-4 w-full text-gray-700 bg-gray-50 rounded-lg border border-gray-200 transition-all duration-200 cursor-pointer hover:shadow-md dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-gray-100 dark:hover:bg-gray-600"
@click="quickProblemHandle(item.str)"
>
<div class="flex items-center">
<el-icon class="mr-3 text-blue-500 dark:text-blue-400">
<EditPen />
</el-icon>
<span class="font-medium">{{ item.str }}</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { EditPen } from '@element-plus/icons-vue';
import MdRenderer from '/@/components/MdRenderer/MdRenderer.vue';
import type { Dataset, PrologueItem } from '../ts/index';
import { processPrologueItems } from '../ts/message';
import { useMessage } from '/@/hooks/message';
const props = defineProps({
selectedKnowledge: {
type: Object as () => Dataset,
required: true,
},
prologueList: {
type: Array as () => PrologueItem[],
required: true,
},
});
const baseURL = import.meta.env.VITE_API_URL || '';
const emit = defineEmits(['quickProblem']);
const { error } = useMessage();
// Create a reactive copy of the prologueList to be modified if needed
const prologueItems = ref<PrologueItem[]>([...props.prologueList]);
// Watch for changes in the selectedKnowledge
watch(
() => props.selectedKnowledge,
async (newValue) => {
try {
// 使用通用方法处理 prologue items
prologueItems.value = await processPrologueItems(newValue, props.prologueList);
} catch (err) {
error('Failed to process prologue items');
// 出错时使用原始列表
prologueItems.value = [...props.prologueList];
}
},
{ immediate: true }
);
const quickProblemHandle = (val: string) => {
emit('quickProblem', val);
};
</script>

View File

@@ -0,0 +1,801 @@
<template>
<div class="flex flex-col flex-1 w-full h-full">
<!-- 顶部操作栏-->
<header class="flex justify-end text-gray-700 bg-white dark:text-gray-300 dark:bg-gray-800">
<svg
v-if="showClearButton"
t="1711092183051"
@click="clearStoreMessageList"
class="w-6 h-6 mr-2"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="5251"
width="128"
height="128"
>
<path
d="M274.56 798.997333l19.434667-25.130666-33.792 68.565333a18.133333 18.133333 0 0 0 11.562666 25.536l59.733334 16a18.133333 18.133333 0 0 0 17.28-4.48c20.522667-19.818667 35.626667-35.989333 45.290666-48.469333l19.456-25.130667-33.813333 68.565333a18.133333 18.133333 0 0 0 11.562667 25.536l84.48 22.634667a18.133333 18.133333 0 0 0 17.28-4.48c20.522667-19.84 35.626667-35.989333 45.269333-48.469333l19.456-25.130667-33.813333 68.565333A18.133333 18.133333 0 0 0 535.530667 938.666667l72.106666 19.328a18.133333 18.133333 0 0 0 17.28-4.48c20.522667-19.84 35.626667-36.010667 45.269334-48.490667l19.456-25.130667-33.813334 68.565333a18.133333 18.133333 0 0 0 11.584 25.514667l86.421334 23.338666 3.84-0.213333c13.269333-0.704 29.056-5.034667 43.84-12.8 29.781333-15.701333 48.170667-43.2 52.181333-78.250667 2.133333-18.517333 4.778667-38.549333 8.405333-63.530666 1.642667-11.221333 2.944-20.010667 6.229334-41.834667 11.050667-73.322667 14.634667-101.034667 17.130666-133.674667l0.938667-12.373333 2.837333-2.922667 12.330667-1.344a41.813333 41.813333 0 0 0 24.810667-11.221333c10.730667-10.24 14.805333-25.386667 11.093333-42.197333l-37.546667-171.584c-3.029333-13.696-11.264-27.946667-23.146666-39.829334-11.648-11.626667-25.92-20.138667-39.893334-23.893333L723.626667 331.306667l-2.261334-3.92534L774.250667 130.133333c8.32-31.061333-11.754667-63.744-44.970667-72.64l-79.509333-21.312c-33.194667-8.896-66.922667 9.365333-75.264 40.426667l-52.842667 197.269333-3.925333 2.261334-118.101334-31.63734c-13.994667-3.754667-30.634667-3.498667-46.506666 0.746667-16.256 4.352-30.506667 12.586667-39.957334 22.933333l-118.314666 129.792c-11.605333 12.714667-15.658667 27.84-11.52 42.090667 4.16 14.229333 15.850667 25.194667 25.194667 30.528l13.610666 4.266667 2.133334 3.882666-3.626667 13.802667c-21.12 79.850667-52.885333 136.917333-85.717333 150.890667-47.530667 20.202667-72.938667 49.429333-78.421334 85.034666-5.034667 32.682667 9.28 67.114667 37.589334 91.541334l22.037333 8.341333 74.666667 20.010667a42.666667 42.666667 0 0 0 41.216-11.050667c15.274667-15.274667 26.88-28.032 34.837333-38.293333z m551.381333-396.565333c14.144 3.797333 29.952 19.2 32.768 32l34.56 157.781333a10.666667 10.666667 0 0 1-13.184 12.586667L240.64 433.493333a10.666667 10.666667 0 0 1-5.12-17.493333l108.8-119.36c8.832-9.685333 30.229333-15.146667 44.373333-11.349333l141.333334 37.866666a21.333333 21.333333 0 0 0 26.133333-15.082666l58.304-217.642667a21.333333 21.333333 0 0 1 26.133333-15.082667l77.056 20.650667a21.333333 21.333333 0 0 1 15.082667 26.133333l-58.325333 217.642667a21.333333 21.333333 0 0 0 15.082666 26.112l136.448 36.565333zM315.456 701.568c-33.664 45.141333-64.597333 79.082667-92.8 101.802667l-5.909333 4.778666-2.837334 0.597334-88.106666-24.106667-2.922667-3.2c-13.034667-14.165333-19.370667-31.04-16.981333-46.592 3.285333-21.333333 22.058667-39.338667 53.205333-52.586667 31.722667-13.482667 59.818667-47.104 82.922667-99.904 10.026667-22.954667 18.88-48.725333 26.389333-76.586666l3.882667-14.4 3.904-2.26134 566.165333 151.701334 2.346667 3.306666-0.789334 12.224c-1.984 30.592-30.336 229.397333-32.128 244.906667-2.346667 20.416-11.306667 34.986667-27.605333 44.394667a73.237333 73.237333 0 0 1-21.397333 8.106666l-5.013334 0.725334-60.373333-16.170667 11.242667-20.288c8.277333-14.976 22.656-43.84 43.093333-86.613333a21.12 21.12 0 0 0-9.962667-28.16l-3.136-1.493334a21.333333 21.333333 0 0 0-26.261333 6.485334c-33.642667 45.056-64.533333 78.912-92.672 101.546666l-5.909333 4.757334-2.837334 0.597333-52.544-14.08 11.114667-20.266667c3.562667-6.485333 7.04-13.013333 10.453333-19.626666 7.04-13.504 17.898667-35.797333 32.597334-66.816a21.290667 21.290667 0 0 0-9.984-28.309334l-3.029334-1.450666a21.333333 21.333333 0 0 0-26.368 6.442666c-33.6 45.013333-64.469333 78.826667-92.608 101.482667l-5.909333 4.757333-2.837333 0.597334-52.138667-13.973334 11.114667-20.266666c3.242667-5.888 6.72-12.416 10.453333-19.626667 6.997333-13.461333 17.962667-35.946667 32.896-67.434667a20.970667 20.970667 0 0 0-10.112-28.010666l-3.328-1.536a21.333333 21.333333 0 0 0-26.069333 6.613333c-33.642667 45.056-64.554667 78.976-92.778667 101.696l-5.909333 4.757333-2.837334 0.597334-32.64-8.746667 11.093334-20.245333c3.541333-6.506667 7.04-13.034667 10.453333-19.626667 6.976-13.482667 17.941333-35.968 32.874667-67.456a21.056 21.056 0 0 0-10.069334-28.074667l-3.242666-1.514666a21.333333 21.333333 0 0 0-26.154667 6.549333z"
fill="#333333"
p-id="5252"
></path>
</svg>
<ModelList v-if="showModelList" :modelType="modelType" :support-json="supportJson" />
</header>
<!-- 聊天内容区 -->
<chat-content
:message-list="messageList"
:prologue-list="prologueList"
:selected-knowledge="selectedKnowledge"
:is-finish="isFinish"
:role-alias="roleAlias"
:collapse-states="collapseStates"
@quick-problem="quickProblemHandle"
@copy-text="copyText"
@regenerate="regenerateText"
@stop-generate="stopGenerateText"
ref="chatContentRef"
/>
<!-- 流程参数配置区 -->
<flow-params-config
:flow-id="props.flowId"
:flow-start-params="flowStartParams"
v-model:selected-chat-field="selectedChatField"
v-model:is-collapsed="isFlowParamsCollapsed"
@params-change="handleFlowParamsChange"
ref="flowParamsConfigRef"
/>
<!-- 输入区 -->
<chat-input
:selected-knowledge="selectedKnowledge"
:placeholder="placeholder"
:is-finish="isFinish"
v-model:message-content="messageContent"
v-model:is-reasoning-mode="isReasoningMode"
@send-message="sendOrSave"
@open-dialog="openDialog"
@open-audio="audioRef?.openDialog"
@open-mcp="mcpsRef?.openDialog"
@file-uploaded="handleFileUpload"
@image-uploaded="handleImageUpload"
/>
<!-- 提示词抽屉 -->
<prompts-dialog ref="promptsRef" @refresh="selectPrompt">
<template #title>
<span class="dark:text-gray-200">提示词</span>
</template>
</prompts-dialog>
<func-dialog ref="funcsRef" @refresh="selectFunc" />
<table-dialog ref="tablesRef" @refresh="selectTable" />
<audio-dialog ref="audioRef" @refresh="selectPrompt" />
<mcps-dialog ref="mcpsRef" @refresh="selectMcps" />
</div>
</template>
<script setup lang="ts">
import type { ChatMessage, Dataset, FileBase64 } from '../ts/index';
import { parseTime } from '/@/utils/formatTime';
import { useUserInfo } from '/@/stores/userInfo';
import commonFunction from '/@/utils/commonFunction';
import { Local, Session } from '/@/utils/storage';
import { useMessage } from '/@/hooks/message';
import { useEventSource } from '@vueuse/core';
import { initSseConnection, deleteConversation, defaultWelcomeMessage } from '../ts/gpt';
import { generateConversationKey, withMessageThought, parseWelcomeMessage } from '../ts/message';
import { useDebounceFn } from '@vueuse/core';
import ModelList from './widgets/modelList.vue';
import ChatContent from './chat-content.vue';
import ChatInput from './chat-input.vue';
import FlowParamsConfig from './flow-params-config.vue';
import { getDetails } from '/@/api/knowledge/aiDataset';
import other from '/@/utils/other';
// 导入流程编排相关的API和类型
import { executeFlowSSEWithChat, FlowExecutionCallbacks, getObj } from '/@/api/knowledge/aiFlow';
import { generateUUID } from '/@/utils/other';
const { copyText } = commonFunction();
const PromptsDialog = defineAsyncComponent(() => import('./prompts.vue'));
const FuncDialog = defineAsyncComponent(() => import('./functions.vue'));
const TableDialog = defineAsyncComponent(() => import('./tables.vue'));
const AudioDialog = defineAsyncComponent(() => import('./widgets/audio.vue'));
const McpsDialog = defineAsyncComponent(() => import('./mcps.vue'));
const props = defineProps({
knowledgeId: {
type: String,
default: '0', // 默认使用自由模式
},
showClearButton: {
type: Boolean,
default: true,
},
showModelList: {
type: Boolean,
default: true,
},
roleAlias: {
type: Object,
default: () => ({ user: useUserInfo().userInfos.user.avatar, assistant: 'AI 助手', system: 'System' }),
},
// 允许外部传入知识库对象如果不传则根据ID查找
knowledgeData: {
type: Object as () => Dataset,
default: undefined,
},
functionName: {
type: String,
default: '',
},
// 新增flowId prop支持流程编排对话
flowId: {
type: String,
default: '',
},
});
/**
* 根据知识库ID获取知识库信息
* @param {string} knowledgeId - 知识库ID
* @param {Dataset} knowledgeData - 可选的知识库数据
* @returns {Dataset} 知识库信息
*/
const getKnowledgeBaseInfo = async (knowledgeId: string, knowledgeData?: Dataset): Promise<Dataset> => {
let knowledgeBase = knowledgeData ||
defaultWelcomeMessage.find((k) => k.id === knowledgeId) || { id: knowledgeId, avatarUrl: defaultWelcomeMessage[0].avatarUrl };
// 确保每个知识库都有时间戳
if (knowledgeBase.id !== knowledgeId) {
await getDetails({ id: knowledgeId }).then((res) => {
knowledgeBase = res.data;
});
}
return knowledgeBase;
};
let messageContent = ref('');
let placeholder = ref('Your message...');
const promptsRef = ref();
const funcsRef = ref();
const tablesRef = ref();
const audioRef = ref();
const mcpsRef = ref();
const chatContentRef = ref();
const flowParamsConfigRef = ref();
const functionName = ref(props.functionName);
const messageList = ref<ChatMessage[]>([]);
const collapseStates = ref<{ [key: string]: boolean }>({});
const isFinish = ref(true);
const isReasoningMode = ref(false);
const uploadedFiles = ref<FileBase64[]>([]);
const uploadedImages = ref<FileBase64[]>([]);
const eventSourceRef = ref();
const selectedKnowledge = ref<Dataset>({ id: props.knowledgeId, avatarUrl: '' });
const dataId = ref('');
const mcpId = ref('');
// 根据知识库ID生成会话key
const conversationKey = ref('');
const baseURL = import.meta.env.VITE_API_URL;
// 流程开始节点参数配置
const flowStartParams = ref<any[]>([]);
// 对话字段映射 - 存储选择的对话字段名称
const selectedChatField = ref<string>('');
// 流程参数配置区折叠状态
const isFlowParamsCollapsed = ref(false);
const modelType = computed<('Image' | 'Reason' | 'Chat')[]>(() => {
switch (selectedKnowledge.value?.id) {
case '-3':
return ['Image'];
case '-7':
return ['Reason'];
default:
return ['Chat'];
}
});
const supportJson = computed(() => {
return selectedKnowledge.value?.id === '-2' || selectedKnowledge.value?.id === '-6';
});
/**
* 从流程定义中解析开始节点的参数配置
* @param {string} flowId - 流程ID
* @returns {Promise<any[]>} 开始节点的参数配置
*/
const loadFlowStartParams = async (flowId: string): Promise<any[]> => {
try {
const { data } = await getObj(flowId);
if (data) {
selectedKnowledge.value.welcomeMsg = data.description;
selectedKnowledge.value.avatarUrl = `<svg t="1721410000000" class="w-12 h-12 rounded-full" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1025" width="256" height="256"><path d="M512 1024C230.4 1024 0 793.6 0 512S230.4 0 512 0s512 230.4 512 512-230.4 512-512 512z m0-928c-204.8 0-368 163.2-368 368s163.2 368 368 368 368-163.2 368-368-163.2-368-368-368z" fill="#000000" p-id="1026"></path><path d="M768 512a256 256 0 1 1-512 0 256 256 0 0 1 512 0z" fill="#000000" p-id="1027"></path></svg>`;
const dsl = JSON.parse(data.dsl);
// 查找开始节点
const startNode = dsl.nodes?.find((node: any) => node.type === 'start');
if (startNode && startNode.outputParams) {
// 返回开始节点的输出参数作为流程的输入参数
return startNode.outputParams.map((param: any) => ({
...param,
value: '', // 初始化值为空
}));
}
}
return [];
} catch (error) {
// eslint-disable-next-line no-console
console.error('加载流程参数失败:', error);
return [];
}
};
onMounted(async () => {
// 初始化知识库信息
selectedKnowledge.value = await getKnowledgeBaseInfo(props.knowledgeId, props.knowledgeData);
dataId.value = selectedKnowledge.value?.dataId || '';
mcpId.value = selectedKnowledge.value?.mcpId || '';
// 如果有流程ID加载流程参数配置
if (props.flowId) {
flowStartParams.value = await loadFlowStartParams(props.flowId);
}
// 初始化消息历史
nextTick(() => {
loadMessageHistory();
});
});
const loadMessageHistory = () => {
placeholder.value = selectedKnowledge.value.placeholder || '请输入您的问题';
messageList.value = JSON.parse(Local.get(conversationKey.value)) || [];
};
// Define stopGenerateText before it's used in the watch function
const stopGenerateText = async () => {
try {
eventSourceRef?.value?.value?.close();
} finally {
isFinish.value = true; // 确保无论成功或失败都将isFinish设置为true
}
};
// 当知识库ID变更时重新加载消息历史
watch(
() => props.knowledgeId,
async () => {
// 如果当前有请求,先停止
if (eventSourceRef?.value?.value) {
stopGenerateText();
isFinish.value = true;
}
// 查找或使用默认知识库
selectedKnowledge.value = await getKnowledgeBaseInfo(props.knowledgeId, props.knowledgeData);
dataId.value = selectedKnowledge.value?.dataId || '';
mcpId.value = selectedKnowledge.value?.mcpId || '';
// 更新会话key包含mcpId和dataId
conversationKey.value = generateConversationKey(props.knowledgeId, true, mcpId.value, dataId.value);
loadMessageHistory();
},
{ immediate: true }
);
// 当流程ID变更时重新加载流程参数配置
watch(
() => props.flowId,
async (newFlowId) => {
if (newFlowId) {
flowStartParams.value = await loadFlowStartParams(newFlowId);
// 如果加载了参数,默认展开配置区
if (flowStartParams.value.length > 0) {
isFlowParamsCollapsed.value = false;
}
// 如果有参数且没有选择对话字段,自动选择第一个适合的参数作为对话字段
if (flowStartParams.value.length > 0 && !selectedChatField.value) {
// 优先选择名称包含 prompt、message、input、content 的参数
const preferredField = flowStartParams.value.find(param =>
/prompt|message|input|content|text/i.test(param.name)
);
if (preferredField) {
selectedChatField.value = preferredField.name;
} else {
// 如果没有找到优先字段,选择第一个字符串类型的参数
const firstStringField = flowStartParams.value.find(param =>
param.inputType === 'input' || param.inputType === 'textarea'
);
if (firstStringField) {
selectedChatField.value = firstStringField.name;
}
}
}
} else {
flowStartParams.value = [];
selectedChatField.value = '';
}
},
{ immediate: true }
);
// Watch for changes in the functionName prop
watch(
() => props.functionName,
(newValue) => {
if (newValue) {
functionName.value = newValue;
}
}
);
// Define a debounced scroll function to prevent too many scroll events
const debouncedScrollToBottom = useDebounceFn(() => {
if (chatContentRef.value && isFinish.value === false) {
// Only scroll when actively generating messages
chatContentRef.value.scrollToBottom();
}
}, 300); // Increase debounce time to reduce update frequency
// 流程编排对话方法
const sendFlowChatMessage = async (content: string) => {
try {
// 生成conversationId如果没有的话
let flowConversationId = conversationKey.value;
if (!flowConversationId || flowConversationId === 'default') {
flowConversationId = generateUUID();
}
// 动态构建执行参数,基于流程开始节点的参数配置
const executeParams: Record<string, any> = {};
if (flowStartParams.value && flowStartParams.value.length > 0) {
// 根据参数配置构建执行参数
flowStartParams.value.forEach((param: any) => {
if (param.name === selectedChatField.value) {
// 对话字段使用用户输入的内容
executeParams[param.name] = content;
} else {
// 其他类型的参数使用配置的值或空值
executeParams[param.name] = param.value || '';
}
});
} else {
// 兜底:如果没有参数配置,使用默认的 prompt 参数
executeParams.prompt = content;
}
// 添加流式消息占位符的索引
const aiMessageIndex = messageList.value.length - 1;
// 定义SSE回调
const callbacks: FlowExecutionCallbacks = {
onChatMessage: (content: string, isComplete: boolean) => {
// 实时更新消息内容
const currentMessage = messageList.value[aiMessageIndex];
if (currentMessage && currentMessage.role === 'assistant') {
nextTick(() => {
// 累积拼接消息内容,而不是替换
currentMessage.content = (currentMessage.content || '') + content;
// @ts-ignore - 添加流式标识
currentMessage.stream = !isComplete;
// 保存到本地存储
Local.set(conversationKey.value, JSON.stringify(messageList.value));
});
}
// 滚动到底部
debouncedScrollToBottom();
},
onComplete: () => {
isFinish.value = true;
},
onError: (error: string) => {
isFinish.value = true;
useMessage().error(error || '流程执行失败');
}
};
// 执行流程SSE聊天
const chatResult = await executeFlowSSEWithChat(
{
id: props.flowId,
conversationId: flowConversationId,
params: executeParams,
envs: {},
stream: true
},
callbacks
);
// 处理最终结果
nextTick(() => {
const currentMessage = messageList.value[aiMessageIndex];
if (currentMessage && currentMessage.role === 'assistant') {
if (chatResult.chatMessage && !currentMessage.content) {
currentMessage.content = chatResult.chatMessage;
}
// @ts-ignore - 流程执行结果
currentMessage.result = chatResult.result;
// @ts-ignore - 流式完成标识
currentMessage.stream = false;
currentMessage.time = parseTime(new Date());
// 保存到本地存储
Local.set(conversationKey.value, JSON.stringify(messageList.value));
}
});
} catch (error: any) {
// 更新错误消息
const aiMessageIndex = messageList.value.length - 1;
const currentMessage = messageList.value[aiMessageIndex];
if (currentMessage && currentMessage.role === 'assistant') {
currentMessage.content = `执行失败: ${error.message || '未知错误'}`;
// @ts-ignore - 流式完成标识
currentMessage.stream = false;
currentMessage.time = parseTime(new Date());
// 保存到本地存储
Local.set(conversationKey.value, JSON.stringify(messageList.value));
}
isFinish.value = true;
useMessage().error(error.message || '流程执行失败');
}
};
const sendChatMessage = async (content: string = messageContent.value) => {
isFinish.value = false;
const selectedModel = Local.get(`selectedAiModel:${modelType.value}`);
const requestMessage: ChatMessage = {
modelName: selectedModel?.name,
conversationId: conversationKey.value,
role: 'user',
content,
time: parseTime(new Date()),
datasetId: selectedKnowledge.value.id,
extDetails: {
// @ts-ignore - 使用可能存在的知识库dataId或者本地dataId
dataId: dataId.value,
funcName: functionName.value,
mcpId: mcpId.value,
files: uploadedFiles.value.length > 0 ? uploadedFiles.value : undefined,
images: uploadedImages.value.length > 0 ? uploadedImages.value : undefined,
},
websearch: selectedKnowledge.value?.id === '-7' && isReasoningMode.value,
};
try {
messageList.value.push(requestMessage);
clearMessageContent();
messageList.value.push({
role: 'assistant',
content: '',
// @ts-ignore
_startThinkTime: Date.now(),
});
nextTick(() => {
debouncedScrollToBottom();
});
// 判断是否为流程编排对话
if (props.flowId) {
// 使用流程编排SSE方式
await sendFlowChatMessage(content);
} else {
// 使用原有的普通聊天方式
const { data } = await initSseConnection(requestMessage);
sseMessageSend(data);
}
} catch (error: any) {
appendLastMessageContent({ message: '网络通信超时,请稍后再试' });
isFinish.value = true;
}
};
/**
* 从会话存储中获取访问令牌
* @returns {string} 访问令牌
*/
const token = computed(() => {
return Session.getToken();
});
const tenant = computed(() => {
return Session.getTenant();
});
/**
* 创建websocket 链接
*/
const sseMessageSend = (messageKey: string) => {
// baseURL
const { eventSource, error } = useEventSource(
`${baseURL}${other.adaptationUrl('/knowledge/chat/msg/list')}?access_token=${token.value}&TENANT-ID=${tenant.value}&key=${messageKey}`,
[],
{
autoReconnect: {
retries: 1,
delay: 1000,
onFailed() {
appendLastMessageContent({ message: '网络通信超时,请稍后再试' });
isFinish.value = true;
},
},
}
);
try {
eventSourceRef.value = eventSource;
if (!error.value) {
let lastScrollTime = 0; // Track last scroll time
eventSource.value?.addEventListener('message', (result) => {
let resultData = JSON.parse(result.data);
if (eventSource.value?.OPEN && resultData.message === '[DONE]') {
isFinish.value = true;
eventSource.value?.close();
Local.set(conversationKey.value, JSON.stringify(messageList.value));
return;
}
if (eventSource.value?.OPEN && resultData.message !== 'pong') {
appendLastMessageContent(resultData);
// Only trigger scroll if enough time has passed (throttle scrolling)
const now = Date.now();
if (now - lastScrollTime > 500) {
lastScrollTime = now;
debouncedScrollToBottom();
}
}
});
// 添加错误处理监听器
eventSource.value?.addEventListener('error', () => {
appendLastMessageContent({ message: '连接已断开,请重试', finish: true });
isFinish.value = true;
});
} else {
appendLastMessageContent({ message: '网络通信超时,请稍后再试', finish: true });
isFinish.value = true; // 确保出错时也将isFinish设为true
}
} catch (e) {
appendLastMessageContent({ message: '网络通信超时,请稍后再试', finish: true });
isFinish.value = true; // 确保出错时也将isFinish设为true
}
};
const appendLastMessageContent = (result: any) => {
if (messageList.value.length === 0) return;
// Create a copy of the last message to avoid direct reactive updates
const lastMessageIndex = messageList.value.length - 1;
const lastMessage = messageList.value[lastMessageIndex];
// Update properties only if result contains valid data
if (result.message && result.message !== 'null') {
lastMessage.content = (lastMessage.content || '') + result.message;
}
// Only update these properties if they exist in the result
lastMessage.time = parseTime(new Date());
if (result.extLinks) {
lastMessage.extLinks = result.extLinks;
}
if (result.path) {
lastMessage.path = result.path;
}
if (result.chartId) {
lastMessage.chartId = result.chartId;
}
if (result.chartType) {
lastMessage.chartType = result.chartType;
}
// Add reasoning content if present and not "null"
if (result.reasoningContent && result.reasoningContent !== 'null') {
lastMessage.reasoning_content = (lastMessage.reasoning_content || '') + result.reasoningContent;
}
// Add tool info if present
if (result.toolInfo) {
lastMessage.toolInfo = result.toolInfo;
}
// @ts-ignore - Pass start time to calculate thinking duration
withMessageThought(lastMessage, lastMessage._startThinkTime);
// 如果结果中包含finish标志设置isFinish为true
if (result.finish) {
isFinish.value = true;
}
};
const sendOrSave = async () => {
if (!messageContent.value.length || !isFinish.value) return;
// 如果是流程编排模式,使用流程参数配置组件进行验证
if (props.flowId && flowStartParams.value.length > 0 && flowParamsConfigRef.value) {
const validation = flowParamsConfigRef.value.validateRequiredParams();
if (!validation.isValid) {
useMessage().error(`请填写必填参数: ${validation.missingParams.join(', ')}`);
return;
}
}
await sendChatMessage();
};
// 处理流程参数变更
const handleFlowParamsChange = () => {
// 参数变更时可以在这里执行额外的逻辑
// 目前参数是通过双向绑定自动更新的,所以这里暂时不需要额外处理
};
const prologueList = computed(() => {
if (selectedKnowledge.value?.welcomeMsg) {
return parseWelcomeMessage(selectedKnowledge.value?.welcomeMsg);
}
return [];
});
function quickProblemHandle(val: string) {
messageContent.value = val;
sendChatMessage(val);
}
const openDialog = () => {
if (selectedKnowledge.value?.id === '0') {
promptsRef.value.openDialog();
} else if (selectedKnowledge.value?.id === '-1' || selectedKnowledge.value?.id === '-6') {
funcsRef.value.openDialog();
} else if (selectedKnowledge.value?.id === '-2') {
tablesRef.value.openDialog();
} else if (selectedKnowledge.value?.id === '-3' || selectedKnowledge.value?.id === '-7' || selectedKnowledge.value?.id === '-8') {
mcpsRef.value.openDialog();
}
};
const selectPrompt = (prompt: string) => {
messageContent.value = prompt;
};
const selectFunc = (func: { showInfo: string; funcName: string }) => {
const key = generateConversationKey(props.knowledgeId, false, mcpId.value, dataId.value);
// 调用后台缓存
deleteConversation(key);
placeholder.value = func.showInfo;
functionName.value = func.funcName;
// 发送
sendOrSave();
};
/**
* 选择数据集
* @param id 表ID
*/
const selectTable = (id: string) => {
dataId.value = id;
sendOrSave();
};
const clearMessageContent = () => (messageContent.value = '');
const clearStoreMessageList = () => {
// 调用后台缓存
deleteConversation(conversationKey.value);
Local.remove(conversationKey.value);
// Close any active event source
if (eventSourceRef?.value?.value) {
eventSourceRef.value.value.close();
}
// 清空会话但保留知识库配置
messageList.value = [];
clearMessageContent();
isFinish.value = true;
// 重置上传的文件和图片
uploadedFiles.value = [];
uploadedImages.value = [];
// 重置占位符为知识库默认值
placeholder.value = selectedKnowledge.value.placeholder || '请输入您的问题';
};
const scrollToBottom = () => {
debouncedScrollToBottom();
};
const regenerateText = async () => {
try {
// 获取messageList 最后一个 role: "assistant" 的消息
const lastAssistantMessage = messageList.value
.slice()
.reverse()
.find((item) => item.role === 'assistant');
// 获取messageList 最后一个 role: "user" 的消息
const lastUserMessage = messageList.value
.slice()
.reverse()
.find((item) => item.role === 'user');
messageContent.value = lastUserMessage?.content || '';
// 删除最后一个 role: "assistant" 的消息 和 最后一个 role: "user" 的消息
messageList.value = messageList.value.filter((item) => item !== lastAssistantMessage && item !== lastUserMessage);
await sendChatMessage();
Local.set(conversationKey.value, JSON.stringify(messageList.value));
} catch (error) {
useMessage().error('重新生成失败,请稍后再试');
isFinish.value = true; // 确保出错时也将isFinish设为true
}
};
/**
* 处理文件上传
* @param {FileBase64} fileData - 已转换为base64的文件数据
*/
const handleFileUpload = (fileData: FileBase64) => {
uploadedFiles.value = [fileData];
};
/**
* 处理图片上传
* @param {FileBase64} imageData - 已转换为base64的图片数据
*/
const handleImageUpload = (imageData: FileBase64) => {
// 直接保存转换好的base64图片数据
uploadedImages.value = [imageData];
};
const selectMcps = (mcp: { mcpId: string; mcpName: string }) => {
// Optionally add the MCP name to the message content
placeholder.value = `使用 MCP: ${mcp.mcpName}`;
mcpId.value = mcp.mcpId;
};
// 对外暴露方法
defineExpose({
sendChatMessage,
clearStoreMessageList,
regenerateText,
stopGenerateText,
scrollToBottom,
setConversationId: (conversationId: string) => {
// Since conversationKey is now a computed property, we should store the conversationId in a ref
// that can be used to load the specific conversation history
const customConversationId = ref(conversationId);
messageList.value = JSON.parse(Local.get(customConversationId.value)) || [];
},
getMessages: () => messageList.value,
getSelectedKnowledge: () => selectedKnowledge.value,
getFlowStartParams: () => flowStartParams.value,
setFlowParams: (params: any[]) => {
flowStartParams.value = params;
},
getSelectedChatField: () => selectedChatField.value,
setSelectedChatField: (fieldName: string) => {
selectedChatField.value = fieldName;
},
getFlowParamsCollapsed: () => isFlowParamsCollapsed.value,
setFlowParamsCollapsed: (collapsed: boolean) => {
isFlowParamsCollapsed.value = collapsed;
},
toggleFlowParamsCollapsed: () => {
isFlowParamsCollapsed.value = !isFlowParamsCollapsed.value;
},
});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,249 @@
<template>
<div
v-if="flowId && flowStartParams.length > 0"
class="mx-auto mb-4 bg-white border-t border-gray-200 shadow-lg flow-params-container dark:border-gray-700 dark:bg-gray-900"
style="width: 50%;"
>
<!-- 配置区头部 -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-800">
<div class="flex items-center space-x-2">
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
<h3 class="text-sm font-medium text-gray-800 dark:text-gray-200">流程参数配置</h3>
<span class="px-2 py-1 text-xs text-blue-600 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-300">
{{ flowStartParams.length }} 个参数
</span>
</div>
<button
@click="toggleCollapse"
class="flex items-center px-2 py-1 space-x-1 text-xs text-gray-600 transition-colors rounded dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span>{{ isCollapsed ? '展开' : '收起' }}</span>
<svg
:class="{ 'rotate-180': !isCollapsed }"
class="w-3 h-3 transition-transform duration-200"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<!-- 配置区内容 -->
<transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-in"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-96"
leave-from-class="opacity-100 max-h-96"
leave-to-class="opacity-0 max-h-0"
>
<div v-show="!isCollapsed" class="p-4 space-y-4 overflow-y-auto max-h-96">
<!-- 对话字段选择 -->
<div class="p-3 rounded-lg bg-gradient-to-r from-amber-50 dark:from-amber-900/20 dark:to-yellow-900/20 border-amber-200 dark:border-amber-800">
<div class="p-3 transition-colors border rounded-lg border-amber-200 param-item bg-amber-50/50 dark:bg-amber-900/10 dark:border-amber-800 hover:border-amber-300 dark:hover:border-amber-600">
<label class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-amber-800 dark:text-amber-200">对话字段</span>
<span class="px-1.5 py-0.5 text-xs bg-amber-100 text-amber-600 dark:bg-amber-900 dark:text-amber-300 rounded">必选</span>
</label>
<select
:value="selectedChatField"
@change="handleChatFieldChange"
class="w-full px-3 py-2 text-sm text-gray-900 transition-colors bg-white border rounded-md dark:bg-gray-800 border-amber-300 dark:border-amber-700 dark:text-gray-100 focus:ring-2 focus:ring-amber-500 focus:border-transparent"
>
<option value="">请选择对话字段</option>
<option v-for="option in chatFieldOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<div class="flex items-start mt-2 space-x-2 text-xs text-amber-700 dark:text-amber-300">
<svg class="w-3 h-3 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<span>选择的字段值将自动使用底部输入框的内容</span>
</div>
</div>
</div>
<!-- 其他参数配置 -->
<div v-if="displayParams.length > 0">
<div class="flex items-center mb-3 space-x-2">
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 4a1 1 0 00-2 0v7.268a2 2 0 000 3.464V16a1 1 0 102 0v-1.268a2 2 0 000-3.464V4zM11 4a1 1 0 10-2 0v1.268a2 2 0 000 3.464V16a1 1 0 102 0V8.732a2 2 0 000-3.464V4zM15 4a1 1 0 00-2 0v7.268a2 2 0 000 3.464V16a1 1 0 102 0v-1.268a2 2 0 000-3.464V4z" />
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">其他参数</span>
<span class="text-xs text-gray-500 dark:text-gray-400">({{ displayParams.length }} )</span>
</div>
<div class="space-y-4">
<div v-for="(param, index) in displayParams" :key="index" class="p-3 transition-colors border border-gray-200 rounded-lg param-item bg-gray-50 dark:bg-gray-800 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600">
<label class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ param.name }}</span>
<span v-if="param.required" class="px-1.5 py-0.5 text-xs bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300 rounded">必填</span>
</label>
<input
v-if="param.inputType === 'input'"
v-model="param.value"
@input="handleParamChange"
type="text"
class="w-full px-3 py-2 text-sm text-gray-900 transition-colors bg-white border border-gray-300 rounded-md dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:placeholder="`请输入${param.name}`"
/>
<input
v-else-if="param.inputType === 'number'"
v-model.number="param.value"
@input="handleParamChange"
type="number"
class="w-full px-3 py-2 text-sm text-gray-900 transition-colors bg-white border border-gray-300 rounded-md dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:placeholder="`请输入${param.name}`"
/>
<textarea
v-else-if="param.inputType === 'textarea'"
v-model="param.value"
@input="handleParamChange"
rows="2"
class="w-full px-3 py-2 text-sm text-gray-900 transition-colors bg-white border border-gray-300 rounded-md resize-none dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:placeholder="`请输入${param.name}`"
></textarea>
<select
v-else-if="param.inputType === 'select'"
v-model="param.value"
@change="handleParamChange"
class="w-full px-3 py-2 text-sm text-gray-900 transition-colors bg-white border border-gray-300 rounded-md dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">请选择{{ param.name }}</option>
<option v-for="option in param.options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<div v-if="param.type === 'prompt' || param.type === 'message'" class="flex items-center mt-2 space-x-1 text-xs text-blue-600 dark:text-blue-400">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<span>此参数将自动使用您的消息内容</span>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import type { FlowParam, FlowParamsValidationResult, ChatFieldOption } from '../ts/flow-params';
const props = defineProps<{
flowId: string;
flowStartParams: FlowParam[];
selectedChatField: string;
isCollapsed: boolean;
}>();
const emit = defineEmits<{
'update:selectedChatField': [value: string];
'update:isCollapsed': [value: boolean];
'paramsChange': [params: FlowParam[]];
}>();
// 计算属性:过滤掉对话字段的参数列表
const displayParams = computed(() => {
return props.flowStartParams.filter(param => param.name !== props.selectedChatField);
});
// 计算属性:可用作对话字段的参数选项
const chatFieldOptions = computed((): ChatFieldOption[] => {
return props.flowStartParams.map(param => ({
label: param.name,
value: param.name
}));
});
// 处理折叠状态切换
const toggleCollapse = () => {
emit('update:isCollapsed', !props.isCollapsed);
};
// 处理对话字段变更
const handleChatFieldChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
emit('update:selectedChatField', target.value);
};
// 处理参数值变更
const handleParamChange = () => {
// 触发参数变更事件,通知父组件
emit('paramsChange', props.flowStartParams);
};
// 验证必填参数
const validateRequiredParams = (): FlowParamsValidationResult => {
const missingParams = props.flowStartParams.filter(param =>
param.required &&
param.type !== 'prompt' &&
param.type !== 'message' &&
param.name !== props.selectedChatField && // 排除对话字段
!param.value
);
return {
isValid: missingParams.length === 0,
missingParams: missingParams.map(p => p.name)
};
};
// 对外暴露方法
defineExpose({
validateRequiredParams
});
</script>
<style scoped>
.flow-params-container {
position: relative;
z-index: 10;
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1), 0 -2px 4px -1px rgba(0, 0, 0, 0.06);
}
.param-item {
transition: all 0.2s ease-in-out;
}
.param-item:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* 自定义滚动条样式 */
.max-h-96::-webkit-scrollbar {
width: 6px;
}
.max-h-96::-webkit-scrollbar-track {
background: transparent;
}
.max-h-96::-webkit-scrollbar-thumb {
background: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
.max-h-96::-webkit-scrollbar-thumb:hover {
background: rgba(156, 163, 175, 0.7);
}
/* 深色模式下的滚动条 */
.dark .max-h-96::-webkit-scrollbar-thumb {
background: rgba(75, 85, 99, 0.5);
}
.dark .max-h-96::-webkit-scrollbar-thumb:hover {
background: rgba(75, 85, 99, 0.7);
}
/* 确保过渡动画平滑 */
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<el-drawer title="选择功能" v-model="functionVisible" close-on-click-modal>
<!-- Prompt cards -->
<ul role="list" class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-2">
<li class="col-span-2 rounded-lg shadow bg-slate-50 dark:bg-slate-900" v-for="func in functionList">
<div class="flex justify-between items-center p-6 space-x-6 w-full">
<button class="flex-1 truncate group" @click="selectFunction(func)">
<div class="flex items-center space-x-3">
<h3
class="text-sm font-medium truncate transition-colors text-slate-900 group-hover:text-blue-600 dark:text-slate-200 dark:group-hover:text-blue-600"
>
{{ func.funcName }}
</h3>
</div>
<p class="mt-1 text-sm truncate text-slate-500">
{{ func.showInfo }}
</p>
</button>
<button
class="flex flex-col flex-shrink-0 gap-y-1 items-center p-1 text-xs rounded-lg transition-colors text-slate-900 hover:text-blue-600 focus:text-blue-600 dark:text-slate-200"
>
<svg
@click="selectFunction(func)"
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 4h6a2 2 0 0 1 2 2v14l-5 -3l-5 3v-14a2 2 0 0 1 2 -2"></path>
</svg>
</button>
</div>
</li>
</ul>
</el-drawer>
</template>
<script setup lang="ts" name="AifunctionsDialog">
import { functions } from '/@/api/knowledge/aiPrompt';
const emit = defineEmits(['refresh']);
const functionVisible = ref(false);
const functionList = ref([]);
/**
* 选中提示词
* @param function
*/
const selectFunction = (func: any) => {
// Close the function dialog
functionVisible.value = false;
// Emit a 'refresh' event with the selected function
emit('refresh', func);
};
/**
* 查询所有提示词.
*/
const querySearchAsync = async () => {
const { data } = await functions();
functionList.value = data;
};
/**
* 打开提示词选择界面.
*/
const openDialog = () => {
functionVisible.value = true;
};
onMounted(() => {
querySearchAsync();
});
// Expose the openDialog function
defineExpose({
openDialog,
});
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div class="overflow-y-auto h-[90vh] p-3 mb-9 pb-20">
<div
v-for="knowledge in defaultWelcomeMessage"
:key="knowledge.id"
@click="selectKnowledge(knowledge)"
:class="{
'bg-blue-50 dark:bg-blue-900 border-l-4 border-blue-500': selectedKnowledge?.id === knowledge.id,
'hover:bg-gray-100 dark:hover:bg-gray-700': selectedKnowledge?.id !== knowledge.id,
}"
class="flex items-center p-2 mb-4 rounded-md cursor-pointer"
>
<div class="mr-3 w-12 h-12 bg-gray-300 rounded-full dark:bg-gray-600">
<div v-html="knowledge.avatarUrl" />
</div>
<div class="flex-1">
<h2 class="text-base font-black dark:text-gray-200">{{ knowledge.name }}</h2>
<p class="text-gray-400 dark:text-gray-500">{{ knowledge.description }}</p>
</div>
</div>
<div
@click="selectKnowledge(knowledge)"
v-for="knowledge in knowledges"
:key="knowledge.id"
:class="{
'bg-blue-50 dark:bg-blue-900 border-l-4 border-blue-500': selectedKnowledge?.id === knowledge.id,
'hover:bg-gray-100 dark:hover:bg-gray-700': selectedKnowledge?.id !== knowledge.id,
}"
class="flex items-center p-2 mb-4 rounded-md cursor-pointer"
>
<div class="mr-3 w-12 h-12 bg-gray-300 rounded-full dark:bg-gray-600">
<img :src="baseURL + knowledge.avatarUrl" alt="User Avatar" class="w-12 h-12 rounded-full" />
</div>
<div class="flex-1">
<h2 class="text-base font-black dark:text-gray-200">{{ knowledge.name }}</h2>
<p class="text-gray-400 dark:text-gray-500">{{ knowledge.description }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts" name="AiPromptsDialog">
import { fetchDataList } from '/@/api/knowledge/aiDataset';
import type { Dataset } from '/@/views/knowledge/aiChat/ts';
import { defaultWelcomeMessage } from '/@/views/knowledge/aiChat/ts/gpt';
const emit = defineEmits(['refresh']);
// Accept initialKnowledgeId as prop
const props = defineProps({
initialKnowledgeId: {
type: String,
default: '0',
},
});
const knowledges = ref<Dataset[]>([]);
// 默认选择第一个【自由模式】或根据props初始化
const selectedKnowledge = ref<Dataset>(defaultWelcomeMessage[0]);
const selectKnowledge = (knowledge: Dataset) => {
selectedKnowledge.value = knowledge;
emit('refresh', knowledge);
};
// Initialize the selected knowledge based on the ID
const initializeSelectedKnowledge = (knowledgeId: string) => {
// Find in default welcome messages
const knowledge = defaultWelcomeMessage.find((item) => item.id === knowledgeId);
if (knowledge) {
selectedKnowledge.value = knowledge;
// Also emit the refresh event to update the parent component
emit('refresh', knowledge);
}
};
const fetchKnowledges = async () => {
const { data } = await fetchDataList();
knowledges.value = data;
};
onMounted(() => {
fetchKnowledges();
// Set initial selection based on props
if (props.initialKnowledgeId !== '0') {
initializeSelectedKnowledge(props.initialKnowledgeId);
}
});
// Watch for changes to the initialKnowledgeId prop
watch(
() => props.initialKnowledgeId,
(newValue) => {
if (newValue && newValue !== '0') {
initializeSelectedKnowledge(newValue);
}
},
{ immediate: true }
);
</script>

View File

@@ -0,0 +1,89 @@
<template>
<el-drawer title="选择 MCP" v-model="mcpVisible" close-on-click-modal>
<!-- MCP cards -->
<ul role="list" class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-2">
<li class="col-span-2 rounded-lg shadow bg-slate-50 dark:bg-slate-900" v-for="mcp in mcpList" :key="mcp.mcpId">
<div class="flex justify-between items-center p-6 space-x-6 w-full">
<button class="flex-1 truncate group" @click="selectMcp(mcp)">
<div class="flex items-center space-x-3">
<h3
class="text-sm font-medium truncate transition-colors text-slate-900 group-hover:text-blue-600 dark:text-slate-200 dark:group-hover:text-blue-600"
>
{{ mcp.name }}
</h3>
</div>
<p class="mt-1 text-sm truncate text-slate-500">
{{ mcp.description || '无描述' }}
</p>
</button>
<button
class="flex flex-col flex-shrink-0 gap-y-1 items-center p-1 text-xs rounded-lg transition-colors text-slate-900 hover:text-blue-600 focus:text-blue-600 dark:text-slate-200"
>
<svg
@click="selectMcp(mcp)"
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 4h6a2 2 0 0 1 2 2v14l-5 -3l-5 3v-14a2 2 0 0 1 2 -2"></path>
</svg>
</button>
</div>
</li>
</ul>
</el-drawer>
</template>
<script setup lang="ts" name="AiMcpsDialog">
import { list } from '/@/api/knowledge/aiMcpConfig';
const emit = defineEmits(['refresh']);
const mcpVisible = ref(false);
const mcpList = ref([]);
/**
* 选中MCP
* @param mcp
*/
const selectMcp = (mcp: any) => {
// Close the MCP dialog
mcpVisible.value = false;
// Emit a 'refresh' event with the selected MCP
emit('refresh', { mcpId: mcp.mcpId, mcpName: mcp.name });
};
/**
* 查询所有MCP.
*/
const queryMcpList = async () => {
try {
const { data } = await list();
mcpList.value = data;
} catch (error) {
console.error('Failed to fetch MCP list:', error);
}
};
/**
* 打开MCP选择界面.
*/
const openDialog = () => {
mcpVisible.value = true;
// Refresh the list when opening
queryMcpList();
};
onMounted(() => {
queryMcpList();
});
// Expose the openDialog function
defineExpose({
openDialog,
});
</script>

View File

@@ -0,0 +1,75 @@
<template>
<el-drawer title="提示词" v-model="promptVisible" close-on-click-modal>
<div class="max-w-xl rounded-lg h-full py-4 dark:border-gray-700">
<div class="mx-2">
<input
id="search-chats"
type="text"
class="w-full rounded-lg border border-slate-300 dark:border-gray-700 bg-slate-50 dark:bg-gray-800 p-3 pr-10 text-sm text-slate-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-600"
placeholder="查询提示词"
v-model="queryString"
@keydown.enter="querySearchAsync"
required
/>
</div>
<ul role="list" class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-2">
<li class="col-span-2 rounded-lg mt-2 shadow dark:bg-gray-800" v-for="prompt in promptList">
<div class="flex w-full items-center justify-between space-x-6 p-6">
<button class="group flex-1 truncate" @click="selectPrompt(prompt)">
<div class="flex items-center space-x-3">
<h3 class="truncate text-sm font-medium text-slate-900 dark:text-gray-200 transition-colors group-hover:text-blue-600">
{{ prompt.act }}
</h3>
</div>
<p class="mt-1 truncate text-sm text-slate-500 dark:text-gray-400">
{{ prompt.prompt }}
</p>
</button>
</div>
</li>
</ul>
</div>
</el-drawer>
</template>
<script setup lang="ts" name="AiPromptsDialog">
import {list} from "/@/api/knowledge/aiPrompt";
const emit = defineEmits(['refresh']);
const promptVisible = ref(false)
const promptList = ref([])
const queryString = ref('')
/**
* 选中提示词
* @param prompt
*/
const selectPrompt = (prompt: any) => {
// Close the prompt dialog
promptVisible.value = false
// Emit a 'refresh' event with the selected prompt
emit('refresh', prompt.prompt);
}
/**
* 查询所有提示词.
*/
const querySearchAsync = async () => {
const {data} = await list({act: queryString.value})
promptList.value = data
}
/**
* 打开提示词选择界面.
*/
const openDialog = () => {
querySearchAsync()
promptVisible.value = true
}
// Expose the openDialog function
defineExpose({
openDialog
});
</script>

View File

@@ -0,0 +1,113 @@
<template>
<el-drawer title="数据表" v-model="tableVisible" close-on-click-modal size="40%">
<el-form :model="state.queryForm" @keyup.enter="getDataList" ref="queryRef">
<el-row class="mb-2" :gutter="20">
<el-col :span="12">
<el-form-item label="数据集" prop="datasetName">
<el-input placeholder="请输入数据集名称" style="max-width: 200px" v-model="state.queryForm.datasetName" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item>
<el-button @click="getDataList" icon="search" type="primary">
{{ $t('common.queryBtn') }}
</el-button>
<el-button icon="Refresh" @click="resetQuery">{{ $t('common.resetBtn') }}</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-row class="mb-2">
已选数据集:
<el-tag v-if="selectedDatasetName" class="ml-2" closable @close="handleClose" :disable-transitions="false">
{{ selectedDatasetName }}
</el-tag>
</el-row>
<!-- Table Content -->
<el-row>
<el-table
ref="tableRef"
:data="state.dataList"
style="width: 100%"
v-loading="state.loading"
border
row-key="id"
width="30%"
@row-dblclick="handleRowDblClick"
highlight-current-row
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
>
<el-table-column :label="t('table.index')" type="index" width="60" />
<el-table-column label="数据集" prop="datasetName" show-overflow-tooltip />
<el-table-column label="描述" prop="description" show-overflow-tooltip />
</el-table>
<pagination @current-change="currentChangeHandle" @size-change="sizeChangeHandle" v-bind="state.pagination" />
</el-row>
</el-drawer>
</template>
<script setup lang="ts" name="AiTablesDialog">
import { fetchList } from '/@/api/knowledge/aiData';
import { BasicTableProps, useTable } from '/@/hooks/table';
import { useI18n } from 'vue-i18n';
import { useMessage } from '/@/hooks/message';
const emit = defineEmits(['refresh']);
const tableVisible = ref(false);
const { t } = useI18n();
const queryRef = ref();
const tableRef = ref();
// 选中的数据ID
const selectedDataId = ref<string | null>(null);
// 选中的数据集名称
const selectedDatasetName = ref<string | null>(null);
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {
datasetName: '',
},
pageList: fetchList,
createdIsNeed: false,
});
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state);
// 双击行事件
const handleRowDblClick = (row: any) => {
selectedDataId.value = row.dataId;
selectedDatasetName.value = row.datasetName;
// Emit a 'refresh' event with the selected data
emit('refresh', row.dataId );
useMessage().success('已选择数据集: ' + row.datasetName);
};
// 清空搜索条件
const resetQuery = () => {
selectedDataId.value = null;
selectedDatasetName.value = null;
queryRef.value?.resetFields();
getDataList();
};
// 关闭处理
const handleClose = () => {
selectedDataId.value = null;
selectedDatasetName.value = null;
};
/**
* 打开提示词选择界面.
*/
const openDialog = () => {
getDataList();
tableVisible.value = true;
};
// Expose the openDialog function
defineExpose({
openDialog,
});
</script>

View File

@@ -0,0 +1,116 @@
<template>
<el-button class="w-1 h-4 !ml-0" :text="!isPlaying" :icon="isPlaying ? 'VideoPause' : 'VideoPlay'" @click="togglePlayPause" type="primary">
</el-button>
<audio ref="audioElement" preload="auto" style="display: none"></audio>
</template>
<script setup>
import {genTts} from "/@/api/knowledge/aiGen";
import {ref} from 'vue';
import {useMessage} from '/@/hooks/message';
// 接收传入的文本属性
const props = defineProps({
text: {
type: String,
required: true
}
});
// 定义状态变量
const isPlaying = ref(false); // 控制音频是否正在播放
const audioElement = ref(null); // 引用 audio 元素
const audioSrc = ref(null); // 存储生成的音频 URL
const isLocked = ref(false); // 按钮锁控制,防止重复点击
// 切换播放/暂停的主函数
const togglePlayPause = () => {
if (isLocked.value) return; // 如果按钮已锁定,直接返回
isLocked.value = true; // 锁定按钮,防止重复点击
if (isPlaying.value) {
pauseAudio(); // 如果正在播放,则暂停
} else {
playAudio(props.text); // 如果未播放则生成或播放<E692AD><E694BE><EFBFBD>
}
};
// 播放音频的函数
const playAudio = (text) => {
if (audioSrc.value) {
resumeAudio(); // 如果音频已生成,则直接恢复播放
} else {
genTts(text).then((res) => {
const audioBlob = base64ToBlob(res.data, 'audio/wav');
const audioUrl = URL.createObjectURL(audioBlob);
audioSrc.value = audioUrl; // 存储生成的音频 URL
handleAudioPlayPause(audioElement, audioUrl); // 处理音频的播放和暂停
}).catch((error) => {
if (typeof error === 'object' && error !== null && 'msg' in error) {
useMessage().error(error.msg);
} else {
useMessage().error('An error occurred');
}
})
.finally(() => {
isLocked.value = false; // 操作完成后释放锁
});
}
};
// 恢复播放音频的函数
const resumeAudio = () => {
if (audioElement.value && audioElement.value.paused) {
audioElement.value.play().then(() => {
isPlaying.value = true; // 更新状态为播放中
}).catch(error => {
console.error("恢复播放音频时出错:", error); // 捕获并打印错误
}).finally(() => {
isLocked.value = false; // 操作完成后释放锁
});
}
};
// 暂停音频的函数
const pauseAudio = () => {
if (audioElement.value && !audioElement.value.paused) {
audioElement.value.pause(); // 暂停音频
isPlaying.value = false; // 更新状态为暂停
isLocked.value = false; // 释放锁
}
};
// 处理音频播放和暂停的核心函数
const handleAudioPlayPause = (audioRef, src) => {
if (audioRef.value) {
if (!audioRef.value.paused && audioRef.value.src === src) {
audioRef.value.pause(); // 如果正在播放且 URL 相同,则暂停
isPlaying.value = false; // 更新状态为暂停
isLocked.value = false; // 释放锁
} else {
audioRef.value.src = src; // 设置音频 URL
audioRef.value.load(); // 重新加载音频
audioRef.value.onloadedmetadata = () => {
audioRef.value.play().then(() => {
isPlaying.value = true; // 更新状态为播放中
}).catch(error => {
console.error("播放音频时出错:", error); // 捕获并打印错误
}).finally(() => {
isLocked.value = false; // 操作完成后释放锁
});
};
}
}
};
// 将 base64 编码转换为 Blob 对象的辅助函数
function base64ToBlob(base64, fileType) {
const byteCharacters = atob(base64); // 解码 base64 字符串
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i); // 将字符转换为字节
}
const byteArray = new Uint8Array(byteNumbers); // 创建字节数组
return new Blob([byteArray], {type: fileType}); // 创建并返回 Blob 对象
}
</script>

View File

@@ -0,0 +1,62 @@
<template>
<el-dialog v-model="audioVisible" destroy-on-close>
<tapir-widget :time="0.2" :backendEndpoint="backendEndpoint"
title="音频交互"
instructionMessageStart="点击开始录音"
instructionMessageStop="点击停止录音"
listenInstructions="播放您的录音"
successMessageRecorded="录音成功"
successMessageSubmitted="上传成功"
:successfulUpload="successfulUpload"
submitLabel="点击识别"
buttonColor="border border-primary"/>
</el-dialog>
</template>
<script setup lang="ts" name="AiPromptsDialog">
// @ts-ignore
import TapirWidget from 'vue-audio-record';
import 'vue-audio-record/dist/vue-audio-tapir.css';
import {Session} from "/@/utils/storage";
const emit = defineEmits(['refresh']);
const audioVisible = ref(false)
/**
* 从会话存储中获取访问令牌
* @returns {string} 访问令牌
*/
const token = computed(() => {
return Session.getToken();
});
/**
* 从会话存储中获取访问租户
* @returns {string} 租户
*/
const tenant = computed(() => {
return Session.getTenant();
});
const backendEndpoint = computed(() => {
return `${import.meta.env.VITE_API_URL}${import.meta.env.VITE_IS_MICRO == 'false' ? '/admin' : '/knowledge'}/chat/audio?access_token=${token.value}&TENANT-ID=${tenant.value}`
})
const successfulUpload = async (response: any) => {
audioVisible.value = false
const {data} = await response.json()
emit('refresh', JSON.parse(data).text);
}
/**
* 打开提示词选择界面.
*/
const openDialog = () => {
audioVisible.value = true
}
// Expose the openDialog function
defineExpose({
openDialog
});
</script>

View File

@@ -0,0 +1,351 @@
<template>
<div class="flex flex-col w-full h-full bg-white rounded-xl border border-gray-100 shadow-md">
<div class="flex justify-between items-center px-5 py-4 border-b border-gray-100">
<h3 class="text-base font-semibold text-gray-800">{{ title }}</h3>
<div>
<slot name="header-actions">
<el-button class="hover:bg-gray-50" icon="Refresh" size="small" circle @click="refreshHistory" :loading="loading"></el-button>
</slot>
</div>
</div>
<div class="overflow-y-auto flex-grow p-4">
<div v-if="loading && !historyItems.length" class="flex justify-center items-center py-10">
<el-icon class="text-blue-500 animate-spin" size="28"><Loading /></el-icon>
</div>
<div v-else-if="!historyItems.length" class="py-10 text-sm text-center text-gray-500">{{ emptyText }}</div>
<div v-else class="history-container" style="height: calc(100% - 10px); display: flex; flex-direction: column">
<el-table
:data="historyItems"
style="width: 100%; flex: 1"
v-loading="loading"
size="small"
:header-cell-style="{ background: '#f8fafc', color: '#475569', fontWeight: '600', padding: '10px' }"
:cell-style="{ padding: '10px', cursor: 'pointer' }"
highlight-current-row
class="overflow-hidden rounded-lg border border-gray-100 history-table"
@row-dblclick="handleItemClick"
>
<!-- Status column -->
<el-table-column prop="materialStatus" label="状态" width="80" show-overflow-tooltip v-if="showStatusColumn"> </el-table-column>
<!-- Prompt column -->
<el-table-column prop="prompt" label="提示词" min-width="100" show-overflow-tooltip> </el-table-column>
<!-- Creation time column, if available -->
<el-table-column prop="createTime" label="创建时间" width="150" show-overflow-tooltip v-if="showTimeColumn"> </el-table-column>
<!-- Actions column -->
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="flex space-x-2">
<el-button
v-if="materialType === 'Video' && (row.materialStatus === 'InProgress' || row.materialStatus === 'InQueue')"
size="small"
type="primary"
circle
class="!bg-blue-500 hover:!bg-blue-600"
@click.stop="handleRefreshStatus(row)"
:loading="row.refreshing"
>
<el-icon><RefreshRight /></el-icon>
</el-button>
<el-button
size="small"
type="danger"
class="!bg-red-500 hover:!bg-red-600"
icon="Delete"
circle
@click.stop="handleDelete(row)"
:loading="row.deleting"
></el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 内置分页组件 -->
<div class="mt-4" v-if="showPagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="pageSizes"
:total="total"
:layout="paginationLayout"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
small
class="flex justify-center"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, watch } from 'vue';
import { Loading, RefreshRight } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { fetchList, delObjs } from '/@/api/knowledge/aiMaterialLog';
import { getVideoCompletionStatus } from '/@/api/knowledge/aiGen';
import { Local } from '/@/utils/storage';
export interface HistoryItem {
id?: string;
url: string;
thumbnail: string;
prompt: string;
localUrl?: string;
createTime?: string;
deleting?: boolean;
refreshing?: boolean;
materialStatus?: string;
originalUrl?: string;
}
const props = defineProps({
title: {
type: String,
default: '历史生成',
},
materialType: {
type: String,
default: 'Video',
validator: (value: string) => ['Video', 'Image', 'Audio', 'Text'].includes(value),
},
externalItems: {
type: Array as () => HistoryItem[],
default: null,
},
thumbnailAlt: {
type: String,
default: 'Thumbnail',
},
emptyText: {
type: String,
default: '历史记录为空',
},
autoLoad: {
type: Boolean,
default: true,
},
showTimeColumn: {
type: Boolean,
default: true,
},
showStatusColumn: {
type: Boolean,
default: false,
},
showPagination: {
type: Boolean,
default: true,
},
pageSizes: {
type: Array as () => number[],
default: () => [10, 20, 30, 50],
},
paginationLayout: {
type: String,
default: 'total, sizes, prev, pager, next',
},
defaultPageSize: {
type: Number,
default: 10,
},
});
const emit = defineEmits(['item-click', 'update:historyItems']);
// Data
const historyItems = ref<HistoryItem[]>([]);
const loading = ref(false);
const currentPage = ref(1);
const pageSize = ref(props.defaultPageSize);
const total = ref(0);
// Watch for external items
watch(
() => props.externalItems,
(newVal) => {
if (newVal !== null) {
historyItems.value = newVal;
// For external items, set total to the length of the array
total.value = newVal.length;
}
},
{ immediate: true }
);
// Methods
const handleItemClick = (item: HistoryItem) => {
emit('item-click', item);
};
const handleDelete = async (item: HistoryItem) => {
if (!item.id) {
ElMessage.error('无法删除记录ID不存在');
return;
}
try {
// Mark as deleting to show loading state
item.deleting = true;
// Confirm before deletion
await ElMessageBox.confirm('确定要删除这条生成记录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
// Call the deletion API
await handleDeleteItem(item.id);
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败,请稍后重试');
}
} finally {
item.deleting = false;
}
};
const handleRefreshStatus = async (item: HistoryItem) => {
if (!item.originalUrl) {
ElMessage.warning('无法刷新状态任务ID不存在');
return;
}
try {
// Mark as refreshing to show loading state
item.refreshing = true;
const selectedModel = Local.get(`selectedAiModel:Video`);
const { data } = await getVideoCompletionStatus(item.originalUrl, selectedModel?.name);
if (data) {
if (data.status === 'Succeed' && data.results && data.results.videos && data.results.videos.length > 0) {
const firstVideo = data.results.videos[0];
item.url = firstVideo.url;
item.localUrl = firstVideo.url;
item.materialStatus = 'Succeed';
ElMessage.success('视频状态已更新!');
// 视频更新成功后,触发选中显示
handleItemClick(item);
} else if (data.status === 'Failed') {
const reason = data.reason ? `: ${data.reason}` : '';
item.materialStatus = 'Failed';
ElMessage.error(`视频生成失败${reason}`);
} else {
item.materialStatus = data.status || item.materialStatus;
ElMessage.info(`视频状态: ${data.status || '未知'}`);
}
}
refreshHistory();
} catch (error) {
ElMessage.error('刷新状态失败,请稍后重试');
} finally {
item.refreshing = false;
}
};
const refreshHistory = async () => {
// Skip fetching if using external items
if (props.externalItems !== null) return;
loading.value = true;
try {
const { data } = await fetchList({
materialType: props.materialType,
size: pageSize.value,
current: currentPage.value,
});
historyItems.value =
data.records?.map((record: any) => ({
id: record.id,
url: record.localUrl || '',
thumbnail: record.thumbnail || record.localUrl || '',
prompt: record.prompt || '',
localUrl: record.localUrl || '',
createTime: record.createTime || '',
materialStatus: record.materialStatus || record.status || '',
deleting: false,
refreshing: false,
originalUrl: record.originalUrl || '',
})) || [];
total.value = data.total;
emit('update:historyItems', historyItems.value);
} catch (error) {
ElMessage.error('获取历史记录失败');
} finally {
loading.value = false;
}
};
const handleDeleteItem = async (id: string) => {
try {
await delObjs([id]);
ElMessage.success('删除成功');
refreshHistory();
} catch (error) {
ElMessage.error('删除失败,请稍后重试');
throw error; // Re-throw for the parent handler
}
};
// Pagination handlers
const handleSizeChange = (newSize: number) => {
pageSize.value = newSize;
refreshHistory();
};
const handleCurrentChange = (newPage: number) => {
currentPage.value = newPage;
refreshHistory();
};
// Initialization
onMounted(() => {
if (props.autoLoad && props.externalItems === null) {
refreshHistory();
}
});
// Expose methods and values for parent components
defineExpose({
refreshHistory,
handleDeleteItem,
total,
currentPage,
pageSize,
});
</script>
<style scoped>
/* 滚动条样式 */
.history-container::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.history-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.history-container::-webkit-scrollbar-track {
background: #f8fafc;
border-radius: 3px;
}
.history-table :deep(.hover-row) {
background-color: #f0f9ff !important;
}
.history-table :deep(.current-row) {
background-color: #ecf5ff !important;
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<el-dropdown @command="handleCommand" trigger="click">
<span class="flex items-center el-dropdown-link">
<svg
t="1727511423193"
class="mr-2 w-6 h-6"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="4267"
width="256"
height="256"
>
<path
d="M633.6 938.666667c-8.533333 0-17.066667-6.4-21.333333-14.933334-14.933333-44.8-55.466667-72.533333-102.4-72.533333s-87.466667 29.866667-102.4 72.533333c-4.266667 10.666667-14.933333 17.066667-25.6 14.933334-74.666667-21.333333-140.8-61.866667-194.133334-117.333334-8.533333-8.533333-8.533333-21.333333 0-27.733333 17.066667-19.2 25.6-42.666667 25.6-68.266667 0-57.6-46.933333-104.533333-106.666666-104.533333h-10.666667c-10.666667 2.133333-21.333333-6.4-23.466667-17.066667-6.4-29.866667-8.533333-59.733333-8.533333-89.6 0-44.8 6.4-89.6 21.333333-132.266666 2.133333-8.533333 10.666667-14.933333 21.333334-14.933334 59.733333 0 108.8-46.933333 108.8-104.533333 0-17.066667-4.266667-32-10.666667-46.933333-6.4-10.666667-4.266667-21.333333 2.133333-27.733334 53.333333-49.066667 117.333333-83.2 185.6-102.4 10.666667-2.133333 19.2 2.133333 25.6 10.666667 19.2 36.266667 55.466667 57.6 96 57.6s76.8-21.333333 96-57.6c4.266667-8.533333 14.933333-12.8 25.6-10.666667 68.266667 19.2 132.266667 53.333333 185.6 102.4 6.4 6.4 8.533333 17.066667 4.266667 25.6-6.4 14.933333-10.666667 29.866667-10.666667 46.933334 0 57.6 46.933333 104.533333 106.666667 104.533333 8.533333 0 19.2 6.4 21.333333 14.933333 12.8 42.666667 21.333333 87.466667 21.333334 132.266667 0 29.866667-2.133333 59.733333-8.533334 89.6-2.133333 10.666667-12.8 19.2-23.466666 17.066667h-10.666667c-59.733333 0-106.666667 46.933333-106.666667 104.533333 0 25.6 8.533333 49.066667 25.6 68.266667 6.4 8.533333 6.4 21.333333 0 27.733333-59.733333 55.466667-125.866667 96-200.533333 119.466667h-6.4zM512 808.533333c57.6 0 108.8 32 134.4 83.2 53.333333-19.2 102.4-49.066667 145.066667-87.466666-14.933333-23.466667-23.466667-51.2-23.466667-78.933334 0-78.933333 64-145.066667 145.066667-147.2 4.266667-21.333333 4.266667-42.666667 4.266666-64 0-36.266667-4.266667-72.533333-14.933333-106.666666-74.666667-6.4-134.4-70.4-134.4-147.2 0-17.066667 2.133333-34.133333 8.533333-49.066667-40.533333-34.133333-89.6-61.866667-140.8-78.933333-27.733333 40.533333-72.533333 66.133333-123.733333 66.133333s-96-25.6-123.733333-66.133333c-51.2 17.066667-100.266667 42.666667-140.8 78.933333 6.4 14.933333 8.533333 32 8.533333 49.066667 0 76.8-59.733333 140.8-134.4 147.2-8.533333 34.133333-14.933333 70.4-14.933333 106.666666 0 21.333333 2.133333 42.666667 4.266666 64 81.066667 0 145.066667 66.133333 145.066667 145.066667 0 27.733333-8.533333 55.466667-23.466667 78.933333 40.533333 38.4 91.733333 68.266667 145.066667 87.466667 25.6-49.066667 76.8-81.066667 134.4-81.066667z"
fill="#333333"
p-id="4268"
></path>
<path
d="M512 682.666667c-93.866667 0-170.666667-76.8-170.666667-170.666667s76.8-170.666667 170.666667-170.666667 170.666667 76.8 170.666667 170.666667-76.8 170.666667-170.666667 170.666667z m0-298.666667c-70.4 0-128 57.6-128 128s57.6 128 128 128 128-57.6 128-128-57.6-128-128-128z"
fill="#333333"
p-id="4269"
></path>
</svg>
{{ selectedModel?.name }}
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="model in models" :key="model.id" :command="model" :class="{ 'is-active': selectedModel?.id === model.id }">
{{ model.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { list } from '/@/api/knowledge/aiModel';
import { Local } from '/@/utils/storage';
import { providerModels } from '/@/views/knowledge/aiModel/model';
interface AiModel {
id: string;
modelName: string;
name: string;
provider?: string;
model?: string;
modelType?: string;
defaultModel?: string;
}
// 定义模型条目类型
interface ModelEntry {
type: string;
model: string;
json?: boolean;
}
// 提供商模型映射类型
type ProviderModelMap = Record<string, ModelEntry[]>;
interface Props {
modelType: ('Image' | 'Reason' | 'Chat' | 'Video' | 'Voice')[];
supportJson?: boolean;
}
const props = defineProps<Props>();
const models = ref<AiModel[]>([]);
const selectedModel = ref<AiModel | null>(null);
// Add emit definition
const emit = defineEmits(['update:model']);
// 当组件挂载或modelType变更时加载模型
async function loadModels() {
try {
const res = await list({});
// 按modelType过滤模型
let filteredModels = res.data.filter((model: AiModel) => props.modelType.some((type) => type === model.modelType));
// 如果supportJson为true过滤支持图表的模型
if (props.supportJson) {
filteredModels = filteredModels.filter((model: AiModel) => {
const provider = model.provider || '';
const name = model.modelName || '';
// 查找匹配的模型配置
const modelEntries = (providerModels as ProviderModelMap)[provider];
const matchingModel =
modelEntries && Array.isArray(modelEntries)
? modelEntries.find((entry: ModelEntry) => entry.model === name && entry.json === true)
: undefined;
return !!matchingModel;
});
}
models.value = filteredModels;
// 如果supportJson为true直接使用第一个模型
if (props.supportJson) {
selectedModel.value = models.value[0];
} else {
// 否则从本地存储获取选定的模型或使用第一个模型作为默认值
const storedModel = Local.get(`selectedAiModel:${props.modelType.join('-')}`);
selectedModel.value = storedModel ? models.value.find((m) => m.id === storedModel.id) || models.value[0] : models.value[0];
}
// 将选定的模型保存到本地存储
if (selectedModel.value) {
Local.set(`selectedAiModel:${props.modelType.join('-')}`, selectedModel.value);
}
} catch (error) {
console.error('获取模型列表失败', error);
// 静默失败并设置空模型列表
models.value = [];
}
}
// 监听modelType变化
watch(
() => props.modelType,
() => {
loadModels();
},
{ immediate: true }
);
// Handle model selection
const handleCommand = (model: AiModel) => {
selectedModel.value = model;
Local.set(`selectedAiModel:${props.modelType.join('-')}`, model);
emit('update:model', model);
};
// Add watch to emit when selectedModel changes initially
watch(
() => selectedModel.value,
(newVal) => {
if (newVal) {
emit('update:model', newVal);
}
},
{ immediate: true }
);
onMounted(() => {
loadModels();
});
</script>
<style scoped>
.el-dropdown-menu__item.is-active {
color: var(--el-color-primary);
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,502 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<div class="flex flex-col h-screen md:flex-row bg-slate-50">
<!-- 左侧面板: 控制区域 -->
<div class="flex-shrink-0 flex flex-col overflow-y-auto p-5 w-full md:w-1/4 min-w-[280px] bg-white shadow-sm rounded-lg md:mr-4 mb-4 md:mb-0">
<div class="mb-5">
<div class="flex justify-between items-center">
<label class="text-sm font-semibold text-gray-700">模型</label>
<div class="flex-grow ml-4">
<ModelList ref="modelListComponent" :model-type="['Image']" />
</div>
</div>
</div>
<!-- 图片尺寸选择区域 -->
<div class="mb-6">
<label class="block mb-3 text-sm font-semibold text-gray-700">图片尺寸</label>
<div class="grid grid-cols-3 gap-3">
<div
v-for="size in imageSizes"
:key="size.value"
@click="selectedImageSize = size.value"
:class="[
'flex relative justify-center items-center p-2 border rounded-lg cursor-pointer transition-all duration-200',
selectedImageSize === size.value
? 'bg-blue-50 border-blue-500 text-blue-600 ring-2 ring-blue-200'
: 'bg-white border-gray-200 hover:bg-gray-50',
]"
>
<div class="flex flex-col justify-center items-center py-1 w-full">
<div class="overflow-hidden relative mb-2 w-12 h-8 rounded border border-gray-300 border-dashed">
<img v-if="size.iconName" :src="getIconUrl(size.iconName)" :alt="size.label" class="object-contain w-full h-full" />
</div>
<span class="text-xs font-medium">{{ size.label }}</span>
</div>
</div>
</div>
</div>
<div class="space-y-5">
<!-- 图片数量滑块 -->
<div>
<div class="flex justify-between mb-2">
<label class="text-sm font-semibold text-gray-700">图片数量</label>
<span class="text-sm font-medium text-gray-600">{{ numberOfImages }}</span>
</div>
<el-slider v-model="numberOfImages" :min="1" :max="4" class="mx-1"></el-slider>
</div>
<!-- 随机种子输入框 -->
<div>
<label class="block mb-2 text-sm font-semibold text-gray-700">种子</label>
<div class="flex items-center">
<el-input v-model="seed" placeholder="随机种子" class="flex-grow mr-2"></el-input>
<el-button
:icon="RefreshRight"
@click="randomizeSeed"
class="flex-shrink-0 transition-transform duration-300 hover:rotate-180"
></el-button>
</div>
</div>
<!-- 推理步数滑块 -->
<div>
<div class="flex justify-between mb-2">
<label class="text-sm font-semibold text-gray-700">推理步数</label>
<span class="text-sm font-medium text-gray-600">{{ inferenceSteps }}</span>
</div>
<el-slider v-model="inferenceSteps" :min="1" :max="100" :step="1" class="mx-1"></el-slider>
</div>
<!-- 引导强度滑块 -->
<div>
<div class="flex justify-between mb-2">
<label class="text-sm font-semibold text-gray-700">引导强度</label>
<span class="text-sm font-medium text-gray-600">{{ guidanceScale }}</span>
</div>
<el-slider v-model="guidanceScale" :min="1" :max="20" :step="0.1" class="mx-1"></el-slider>
</div>
<!-- 反向提示词输入框 -->
<div>
<label class="block mb-2 text-sm font-semibold text-gray-700">反向提示词</label>
<el-input type="textarea" :rows="3" v-model="negativePrompt" placeholder="不想在图片中看到的内容" class="w-full rounded-lg"></el-input>
</div>
<!-- 上传参考图片 -->
<div>
<div class="flex justify-between items-center mb-2">
<label class="text-sm font-semibold text-gray-700">参考图片 <span class="text-red-500">*</span></label>
<el-upload
class="flex justify-start"
:auto-upload="false"
:limit="1"
:on-change="handleImageUpload"
:on-remove="handleImageRemove"
accept="image/*"
>
<template #trigger>
<div class="flex">
<el-button type="primary" size="small" class="rounded-md">选择图片</el-button>
</div>
</template>
</el-upload>
</div>
<div v-if="!uploadedImageBase64" class="text-xs text-red-500 mt-1">
请上传参考图片
</div>
</div>
</div>
</div>
<!-- 中间面板: 图片显示和提示词输入区域 -->
<div class="flex flex-col flex-grow p-5 mb-4 bg-white rounded-lg shadow-sm md:mr-4 md:mb-0">
<!-- 图片显示区域 -->
<div class="flex overflow-hidden relative flex-col flex-grow mb-6 bg-gray-50 rounded-xl border border-gray-200">
<div v-if="selectedHistoryImage" class="flex justify-center items-center p-2 w-full h-full">
<img
:src="baseURL + selectedHistoryImage"
alt="History Image"
class="object-contain max-w-full max-h-full rounded-lg shadow-md h-[600px]"
/>
<el-button
class="absolute top-3 right-3 backdrop-blur-sm bg-white/80 hover:bg-white"
size="small"
type="default"
circle
@click="selectedHistoryImage = null"
icon="Close"
></el-button>
</div>
<div v-else-if="generatedImageUrls.length > 0" class="w-full h-full">
<el-carousel
:initial-index="currentImageIndex"
height="100%"
arrow="always"
indicator-position="outside"
@change="handleCarouselChange"
class="h-full image-carousel"
>
<el-carousel-item v-for="(url, index) in generatedImageUrls" :key="index" class="flex justify-center items-center h-full">
<div class="flex justify-center items-center p-4 w-full h-full image-container">
<img :src="baseURL + url" alt="Generated Image" class="object-contain max-w-full max-h-full rounded-lg shadow-md h-[600px]" />
</div>
</el-carousel-item>
</el-carousel>
</div>
<div v-else-if="isGenerating" class="flex flex-col justify-center items-center h-full">
<el-icon class="text-blue-500 animate-spin" size="48"><Loading /></el-icon>
<span class="mt-4 font-medium text-gray-600">正在生成图片...</span>
</div>
<div v-else class="flex justify-center items-center h-full">
<div class="p-8 text-center">
<span class="block mb-2 text-gray-400">图片将在这里显示</span>
<span class="text-xs text-gray-400">输入提示词并点击生成按钮</span>
</div>
</div>
</div>
<!-- 示例提示词区域 -->
<div v-if="!selectedHistoryImage" class="mb-5">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-semibold text-gray-700">示例提示词</h3>
<el-tooltip content="点击使用示例提示词" placement="top">
<el-icon class="text-gray-400 cursor-help hover:text-gray-600"><InfoFilled /></el-icon>
</el-tooltip>
</div>
<div class="grid grid-cols-1 gap-2 pb-1 max-h-[200px] overflow-y-auto">
<div
v-for="example in examplePrompts"
:key="example"
@click="prompt = example"
class="flex items-center p-3 bg-white rounded-lg border border-gray-200 transition-all duration-200 cursor-pointer hover:bg-blue-50 hover:border-blue-300"
>
<el-icon class="mr-3 text-blue-500"><ChatLineRound /></el-icon>
<div class="overflow-hidden">
<p class="text-sm truncate">{{ truncateText(example, 60) }}</p>
</div>
</div>
</div>
</div>
<!-- 提示词输入区域 -->
<div class="flex flex-col items-center p-4 mt-auto bg-white rounded-lg border border-gray-200 shadow-sm">
<div v-if="selectedHistoryImage" class="mb-3 w-full text-center">
<el-tag type="info" effect="light" size="small" class="px-3 py-1 mb-1">
<el-icon class="mr-1"><InfoFilled /></el-icon>您正在查看历史图片可以编辑提示词重新生成
</el-tag>
</div>
<div class="flex items-center w-full">
<el-input
v-model="prompt"
placeholder="请输入你的创意指令... "
class="flex-grow mr-4 prompt-input"
@keyup.enter.prevent="handlePromptEnter"
autosize
type="textarea"
:maxlength="500"
:input-style="{
border: 'none',
boxShadow: 'none',
padding: '12px 0px',
lineHeight: '1.5',
resize: 'none',
backgroundColor: selectedHistoryImage ? '#f9f9f9' : 'transparent',
}"
resize="none"
>
</el-input>
<el-button
circle
type="primary"
@click="generateImage"
:loading="isGenerating"
class="flex justify-center items-center w-14 h-14 min-w-[3.5rem] min-h-[3.5rem] rounded-full shadow-md transition-all duration-300 hover:shadow-lg transform hover:scale-105"
:disabled="!prompt.trim()"
>
<el-icon size="26"><Promotion /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 右侧面板: 历史记录 -->
<div class="flex-shrink-0 w-full md:w-1/4">
<HistoryPanel
ref="historyPanelRef"
title="生成历史"
material-type="Image"
thumbnailAlt="Generated Image"
@item-click="handleHistoryItemClick"
:default-page-size="pageSize"
:show-time-column="true"
class="h-full"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { RefreshRight, Promotion, Loading, InfoFilled, ChatLineRound } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import ModelList from './components/widgets/modelList.vue';
import HistoryPanel, { type HistoryItem } from './components/widgets/historyPanel.vue';
import { genImage } from '/@/api/knowledge/aiGen';
import { Local } from '/@/utils/storage';
// Import SVG icons
import imageSize11 from '/@/assets/ai/images/image-size-1-1.svg';
import imageSize12 from '/@/assets/ai/images/image-size-1-2.svg';
import imageSize32 from '/@/assets/ai/images/image-size-3-2.svg';
import imageSize34 from '/@/assets/ai/images/image-size-3-4.svg';
import imageSize169 from '/@/assets/ai/images/image-size-16-9.svg';
import imageSize916 from '/@/assets/ai/images/image-size-9-16.svg';
// SVG icon mapping
const iconMap: Record<string, string> = {
'image-size-1-1': imageSize11,
'image-size-1-2': imageSize12,
'image-size-3-2': imageSize32,
'image-size-3-4': imageSize34,
'image-size-16-9': imageSize169,
'image-size-9-16': imageSize916,
};
// Get the baseURL from environment variables
const baseURL = import.meta.env.VITE_API_URL || '';
// 获取图标URL的辅助函数
const getIconUrl = (iconName: string) => {
return iconMap[iconName] || '';
};
// 文本截断的辅助函数
const truncateText = (text: string, maxLength: number) => {
return text?.length > maxLength ? text.substring(0, maxLength) + '...' : text || '';
};
// 左侧面板: 控制数据
const modelListComponent = ref<any>(null); // 模型选择组件引用
const imageSizes = ref([
{ label: '1:1', value: '1024x1024', iconName: 'image-size-1-1' }, // 方形图片
{ label: '1:2', value: '720x1440', iconName: 'image-size-1-2' }, // 长形竖图
{ label: '3:2', value: '960x1280', iconName: 'image-size-3-2' }, // 电影海报比例
{ label: '3:4', value: '768x1024', iconName: 'image-size-3-4' }, // 肖像/人像比例
{ label: '16:9', value: '1024x576', iconName: 'image-size-16-9' }, // 宽屏比例
{ label: '9:16', value: '720x1280', iconName: 'image-size-9-16' }, // 手机屏幕比例
]);
const selectedImageSize = ref('1024x1024'); // 默认图片尺寸为1:1
const numberOfImages = ref(1); // 默认生成图片数量
const seed = ref(''); // 随机种子值
const inferenceSteps = ref(25); // 推理步数,步数越大,生成质量越高但耗时更长
const guidanceScale = ref(7.5); // 引导强度,值越高越忠于提示词
const negativePrompt = ref<string | null>(null); // 反向提示词,指定不希望在图像中出现的内容
const uploadedImageBase64 = ref<string | null>(null); // 上传图片的Base64编码
const uploadedImageFile = ref<File | null>(null); // 上传的图片文件对象
// 右侧面板: 数据
const generatedImageUrl = ref<string>(''); // 当前显示的生成图片URL
const generatedImageUrls = ref<string[]>([]); // 所有生成的图片URL数组
const currentImageIndex = ref(0); // 当前查看的图片索引
const prompt = ref(''); // 提示词输入
const isGenerating = ref(false); // 是否正在生成图片
const selectedHistoryImage = ref<string | null>(null); // 选中的历史图片URL
const examplePrompts = ref([
// 示例提示词列表
'埃菲尔铁塔被包裹在彩色丝绸中,日落时分,高清摄影,逼真细节,金色光芒',
'场景由三个物体组成:一本古老的书籍,一盏复古油灯和一只沉睡的猫,柔和的光线,写实风格',
'古老的战场虽然战争已经结束但残留的盔甲和武器依然散落薄雾笼罩史诗般的氛围8K渲染',
'纽约城市风光,摩天大楼在雨后闪烁着灯光,湿润的街道反射着霓虹灯,电影质感,广角镜头',
'梦幻森林场景,巨大的蘑菇和发光植物,小精灵在空中飞舞,魔幻色彩,细腻质感,幻想艺术风格',
]);
// 历史记录和分页相关数据
const pageSize = ref(10);
// 左侧面板: 方法
// 生成随机种子
const randomizeSeed = () => {
seed.value = String(Math.floor(Math.random() * 1000000000));
};
// 处理图片上传
const handleImageUpload = (file: any) => {
if (file.raw && file.raw.type && file.raw.type.startsWith('image/')) {
uploadedImageFile.value = file.raw;
const reader = new FileReader();
reader.onload = (e) => {
if (e.target && e.target.result) {
uploadedImageBase64.value = e.target.result.toString(); // 转换为Base64
}
};
reader.readAsDataURL(file.raw);
return false; // 阻止自动上传
}
ElMessage.error('请上传有效的图片文件');
return false;
};
// 处理图片移除
const handleImageRemove = () => {
uploadedImageFile.value = null;
uploadedImageBase64.value = null;
};
// 右侧面板: 方法
// 处理回车键按下事件
const handlePromptEnter = (event: KeyboardEvent) => {
if (event.shiftKey) {
// 允许Shift+Enter换行
return;
}
generateImage(); // 直接回车则生成图片
};
// 历史面板引用
const historyPanelRef = ref<InstanceType<typeof HistoryPanel> | null>(null);
// 处理历史项目点击 - 新方法适配historyPanel组件
const handleHistoryItemClick = (item: HistoryItem) => {
if (item.localUrl) {
// 显示历史图片
selectedHistoryImage.value = item.localUrl;
// 应用提示词
prompt.value = item.prompt || '';
} else {
ElMessage.info('该记录没有图片');
}
};
// 生成图片的核心方法
const generateImage = async () => {
if (!prompt.value.trim()) {
ElMessage.warning('请输入提示词!');
return;
}
if (!uploadedImageBase64.value) {
ElMessage.warning('请上传参考图片!');
return;
}
isGenerating.value = true;
generatedImageUrl.value = ''; // 清除之前的图片
generatedImageUrls.value = []; // 清除之前的图片数组
selectedHistoryImage.value = null; // 清除选中的历史图片
currentImageIndex.value = 0; // 重置索引
try {
// 获取本地存储的选中模型
const selectedModel = Local.get(`selectedAiModel:Image`);
// 准备API调用参数
const params = {
model: selectedModel.name, // 选中的AI模型名称
imageSize: selectedImageSize.value, // 图片尺寸
batchSize: numberOfImages.value, // 生成图片数量
seed: seed.value ? Number(seed.value) : undefined, // 随机种子
numInferenceSteps: inferenceSteps.value, // 推理步数
guidanceScale: guidanceScale.value, // 引导强度
negativePrompt: negativePrompt.value || undefined, // 反向提示词
prompt: prompt.value, // 正向提示词
image: uploadedImageBase64.value || undefined, // 参考图片(用于图像到图像生成)
};
// 调用API生成图片
const { data: imageUrls } = await genImage(params);
if (imageUrls && imageUrls.length) {
generatedImageUrls.value = imageUrls;
generatedImageUrl.value = imageUrls[0]; // 设置第一张图片为当前显示
currentImageIndex.value = 0; // 设置为第一张图片
ElMessage.success('图片生成成功!');
// 生成成功后刷新历史记录
historyPanelRef.value?.refreshHistory();
} else {
ElMessage.error('图片生成失败, 请稍后重试');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ElMessage.error(`生成图片时发生错误,请稍后重试: ${errorMessage}`);
} finally {
isGenerating.value = false; // 无论成功或失败,都重置生成状态
}
};
// 处理轮播图当前图片
const handleCarouselChange = (index: number) => {
currentImageIndex.value = index;
generatedImageUrl.value = generatedImageUrls.value[index];
};
// 组件初始化
onMounted(() => {
// 生成随机种子
randomizeSeed();
});
</script>
<style scoped>
.image-carousel :deep(.el-carousel__arrow) {
background-color: rgba(30, 58, 138, 0.7);
border-radius: 9999px;
transition: all 0.2s;
}
.image-carousel :deep(.el-carousel__arrow:hover) {
background-color: rgba(30, 58, 138, 0.9);
transform: scale(1.1);
}
.image-carousel :deep(.el-carousel__indicators) {
bottom: 10px;
}
.image-carousel :deep(.el-carousel__indicator) {
padding: 0 4px;
}
.image-carousel :deep(.el-carousel__button) {
background-color: rgba(30, 58, 138, 0.5);
border-radius: 4px;
width: 24px;
height: 4px;
transition: all 0.2s;
}
.image-carousel :deep(.el-carousel__indicator.is-active .el-carousel__button) {
background-color: rgba(30, 58, 138, 0.9);
}
/* 滚动条样式 */
.history-container::-webkit-scrollbar,
.max-h-\[200px\]::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.history-container::-webkit-scrollbar-thumb,
.max-h-\[200px\]::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 6px;
}
.history-container::-webkit-scrollbar-track,
.max-h-\[200px\]::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 6px;
}
@media (max-width: 768px) {
.flex-col-reverse {
flex-direction: column-reverse;
}
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<div class="flex overflow-hidden h-screen">
<!-- Left sidebar (only shown when no datasetId in URL) -->
<div v-if="!isDirectChat" class="w-1/4 bg-white border-r border-gray-300 dark:bg-gray-800 dark:border-gray-700">
<knowlegeslist @refresh="selectKnowledge" :initial-knowledge-id="selectedKnowledgeId" />
</div>
<!-- When direct chat mode, use the chat history sidebar component -->
<chat-history-sidebar v-else :knowledge-id="selectedKnowledgeId" @conversation-selected="loadMessage" />
<!-- Right chat window -->
<div class="flex-1" :class="{ 'w-full': !isDirectChat, 'flex-1': isDirectChat }">
<chat-window
ref="chatWindowRef"
:knowledge-id="selectedKnowledgeId"
:knowledge-data="selectedKnowledge"
:flow-id="props.flowId || (route.query.flowId as string) || ''"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Dataset } from './ts/index';
import { defaultWelcomeMessage } from './ts/gpt';
import knowlegeslist from './components/knowleges.vue';
import ChatWindow from './components/chat-window.vue';
import ChatHistorySidebar from './components/chat-history-sidebar.vue';
import { useRoute } from 'vue-router';
import { ref, watch, nextTick, onBeforeMount } from 'vue';
// Define props
const props = defineProps({
datasetId: {
type: String,
default: '',
},
mcpId: {
type: String,
default: '',
},
// 新增flowId prop支持流程编排对话
flowId: {
type: String,
default: '',
},
});
// Get route to access query parameters
const route = useRoute();
// Selected knowledge
const selectedKnowledgeId = ref('0');
const selectedKnowledge = ref<Dataset>(defaultWelcomeMessage[0]);
const chatWindowRef = ref();
// Direct chat mode state
const isDirectChat = ref(false);
// Load a specific message
const loadMessage = (conversationId: string) => {
if (chatWindowRef.value) {
chatWindowRef.value.setConversationId(conversationId);
}
};
// Handle knowledge selection from the sidebar
const selectKnowledge = (knowledge: Dataset) => {
selectedKnowledgeId.value = knowledge.id;
selectedKnowledge.value = knowledge;
};
// Check if there are query parameters to auto-select a knowledge dataset
onBeforeMount(() => {
// Check for datasetId from URL first
if (props.datasetId && props.datasetId !== '') {
// Try to find matching dataset or handle it as needed
const dataset = defaultWelcomeMessage.find((item) => item.id === props.datasetId);
if (dataset) {
selectedKnowledgeId.value = props.datasetId;
selectedKnowledge.value = dataset;
isDirectChat.value = true;
}
}
// If no datasetId in path, check for query parameters as before
else if (route.query.datasetId) {
// Find the dataset in the default welcome messages
const datasetId = route.query.datasetId as string;
const dataset = defaultWelcomeMessage.find((item) => item.id === datasetId);
isDirectChat.value = true;
if (dataset) {
// If we have additional data from aiData page
if (route.query.dataId || route.query.mcpId || route.query.flowId) {
const enhancedDataset = {
...dataset,
dataId: route.query.dataId as string,
mcpId: props.mcpId || (route.query.mcpId as string) || '',
flowId: route.query.flowId as string,
};
selectedKnowledgeId.value = datasetId;
selectedKnowledge.value = enhancedDataset;
} else {
// Just select the dataset
selectedKnowledgeId.value = datasetId;
selectedKnowledge.value = dataset;
}
} else {
selectedKnowledgeId.value = datasetId;
selectedKnowledge.value = {id: datasetId};
}
}
});
// Reset chat for a new conversation while preserving dataset configuration
const resetChat = () => {
if (chatWindowRef.value) {
chatWindowRef.value.clearStoreMessageList();
// Ensure the chat window is scrolled to the starting position
nextTick(() => {
if (chatWindowRef.value) {
chatWindowRef.value.scrollToBottom();
}
});
}
};
// Watch for changes in the route to handle new chat creation
watch(
() => route.query._t,
(newTimestamp, oldTimestamp) => {
if (newTimestamp && newTimestamp !== oldTimestamp) {
resetChat();
}
}
);
</script>

View File

@@ -0,0 +1,43 @@
/**
* 流程参数配置相关的类型定义
*/
export interface FlowParam {
/** 参数名称 */
name: string;
/** 参数类型 */
type: string;
/** 输入控件类型 */
inputType: 'input' | 'number' | 'textarea' | 'select';
/** 是否必填 */
required: boolean;
/** 参数值 */
value: any;
/** 选项列表(当 inputType 为 select 时使用) */
options?: FlowParamOption[];
/** 参数描述 */
description?: string;
/** 默认值 */
defaultValue?: any;
}
export interface FlowParamOption {
/** 选项显示文本 */
label: string;
/** 选项值 */
value: any;
}
export interface FlowParamsValidationResult {
/** 验证是否通过 */
isValid: boolean;
/** 缺失的必填参数名称列表 */
missingParams: string[];
}
export interface ChatFieldOption {
/** 字段显示名称 */
label: string;
/** 字段值 */
value: string;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,68 @@
export interface Dataset {
id: string;
name?: string;
avatarUrl?: string;
welcomeMsg?: string;
description?: string;
placeholder?: string;
dataId?: string;
mcpId?: string;
timestamp?: number;
}
export interface ToolInfo {
name: string;
params: string;
}
export interface ChatMessage {
modelName?: string;
conversationId?: string;
role: 'user' | 'assistant' | 'system';
content: string;
path?: string;
time?: string | null;
datasetId?: string | null;
extLinks?: ExtLink[] | null;
websearch?: boolean | null;
extDetails?: ExtDetails | null;
reasoning_content?: string | null;
thinking_time?: string | null;
chartId?: string | null;
chartType?: string | null;
toolInfo?: ToolInfo | null;
}
export interface PrologueItem {
type: 'md' | 'question';
str: string;
index: number;
}
export interface ExtLink {
name: string;
url: string;
distance: number;
}
export interface ExtDetails {
funcName?: string;
mcpId?: string;
dataId?: string;
table?: Table;
files?: File[] | FileBase64[];
images?: File[] | FileBase64[];
}
export interface FileBase64 {
name: string;
type: string;
size: number;
base64?: string;
url: string;
}
export interface Table {
dsName: string;
tableNames: string[];
}

View File

@@ -0,0 +1,163 @@
import type { ChatMessage, Dataset, PrologueItem } from './index';
import { useUserInfo } from '/@/stores/userInfo';
import { Session } from '/@/utils/storage';
import { getObj } from '/@/api/knowledge/aiMcpConfig';
import { getObj as getDataObj } from '/@/api/knowledge/aiData';
/**
* Process a message to extract any thought content enclosed in <think> tags
* and update the message object accordingly
*/
export function withMessageThought(message: ChatMessage, startTime?: number) {
const content = message.content;
// If message already has reasoning_content, calculate thinking time
if (message.reasoning_content) {
const thinkingTime = startTime ? ((Date.now() - startTime) / 1000).toFixed(1) : '0.5';
message.thinking_time = thinkingTime;
// @ts-ignore - Add isThinking flag
message.isThinking = false;
return message;
}
const thinkPattern = /<think>(.*?)<\/think>/s;
const matches = content.match(thinkPattern);
if (matches) {
const reasoning_content = matches[1].trim();
const remainingContent = content.replace(thinkPattern, '').trim();
message.content = remainingContent;
if (reasoning_content) {
// Calculate thinking time
const thinkingTime = startTime ? ((Date.now() - startTime) / 1000).toFixed(1) : '0.5';
// @ts-ignore - Add reasoning properties
message.reasoning_content = reasoning_content;
message.thinking_time = thinkingTime;
// @ts-ignore - Add isThinking flag
message.isThinking = true;
return message;
}
}
return message;
}
/**
* 检查字符串是否为 Markdown 数组格式(以 '- ' 开头)
* @param {string} val - 要检查的字符串
* @returns {boolean} 是否为 Markdown 数组格式
*/
export const isMdArray = (val: string): boolean => {
return Boolean(val.match(/^-\s.*/m));
};
/**
* 解析欢迎消息字符串为 PrologueItem 数组
* @param {string} welcomeMsg - 欢迎消息字符串
* @returns {PrologueItem[]} 解析后的 PrologueItem 数组
*/
export const parseWelcomeMessage = (welcomeMsg?: string): PrologueItem[] => {
const lines = welcomeMsg?.split('\n');
if (!lines) return [];
return lines
.reduce((pre_array: PrologueItem[], current: string, index: number) => {
const currentObj = isMdArray(current)
? {
type: 'question' as const,
str: current.replace(/^-\s+/, ''),
index: index,
}
: {
type: 'md' as const,
str: current,
index: index,
};
if (pre_array.length > 0) {
const pre = pre_array[pre_array.length - 1];
if (!isMdArray(current) && pre.type === 'md') {
pre.str = [pre.str, current].join('\n');
pre.index = index;
return pre_array;
} else {
pre_array.push(currentObj);
}
} else {
pre_array.push(currentObj);
}
return pre_array;
}, [])
.sort((pre, next) => pre.index - next.index);
};
/**
* 处理 prologue items包括从 MCP 获取元数据的逻辑
* @param {Dataset} selectedKnowledge - 选中的知识库
* @param {PrologueItem[]} initialPrologueList - 初始的 prologue 列表
* @returns {Promise<PrologueItem[]>} 处理后的 prologue items
*/
export const processPrologueItems = async (
selectedKnowledge: Dataset,
initialPrologueList: PrologueItem[]
): Promise<PrologueItem[]> => {
// 如果存在 mcpId从后端获取 MCP 元数据
if (selectedKnowledge?.mcpId) {
const { data } = await getObj(selectedKnowledge.mcpId);
if (data && data.description) {
// 使用 parseWelcomeMessage 解析 MCP 的描述信息
return parseWelcomeMessage(data.description);
}
}
// 如果存在 dataId从后端获取数据集元数据
if (selectedKnowledge?.dataId) {
const { data } = await getDataObj(selectedKnowledge.dataId);
if (data && data.description) {
// 使用 parseWelcomeMessage 解析数据集的描述信息
return parseWelcomeMessage(data.description);
}
}
// 如果没有 mcpId 和 dataId 或获取失败,返回初始列表
return initialPrologueList;
};
/**
* 从会话存储中获取访问令牌
* @returns {string} 访问令牌
*/
const token = computed(() => {
return Session.getToken();
});
/**
* 生成会话存储的key
* @param {string} knowledgeId - 知识库ID
* @param {boolean} notime - 是否不包含时间戳
* @param {string} mcpId - MCP服务ID可选
* @param {string} dataId - 数据ID可选
* @returns {string} 会话存储key
*/
export const generateConversationKey = (knowledgeId: string, notime?: boolean, mcpId?: string, dataId?: string) => {
// 构建基础key
let key = `chat-${knowledgeId}-${useUserInfo().userInfos.user.userId}-${token.value}`;
// 如果有mcpId添加到key中
if (mcpId) {
key += `-mcp-${mcpId}`;
}
// 如果有dataId添加到key中
if (dataId) {
key += `-data-${dataId}`;
}
// 如果需要时间戳添加到key末尾
if (!notime) {
key += `-${Date.now()}`;
}
return key;
};

View File

@@ -0,0 +1,428 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<div class="flex overflow-hidden h-full rounded-xl shadow-lg">
<!-- Left Panel: Controls -->
<div class="w-1/4 min-w-[320px] bg-white p-5 border-r border-slate-200 overflow-y-auto">
<!-- Model Selection -->
<div class="mb-6">
<div class="flex justify-between items-center">
<label class="text-sm font-medium text-slate-700">模型</label>
<div class="flex-grow ml-4">
<ModelList ref="modelListComponent" :model-type="['Video']" />
</div>
</div>
</div>
<!-- Video Size Selection -->
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-slate-700">视频尺寸</label>
<div class="grid grid-cols-3 gap-2">
<div
v-for="size in imageSizes"
:key="size.value"
@click="selectedimageSize = size.value"
:class="[
'flex relative justify-center items-center p-1 border rounded-lg transition-all duration-200',
selectedimageSize === size.value
? 'bg-blue-50 border-blue-500 text-blue-600 shadow-sm'
: 'bg-white border-slate-200 hover:bg-slate-50',
]"
>
<div class="flex flex-col justify-center items-center py-2 w-full">
<div class="flex relative justify-center items-center mb-1 w-10 h-7 rounded border border-dashed border-slate-300">
<img v-if="size.iconName" :src="getIconUrl(size.iconName)" :alt="size.label" class="object-contain w-full h-full" />
</div>
<span class="text-xs font-medium">{{ size.label }}</span>
</div>
</div>
</div>
</div>
<!-- Additional Controls -->
<div class="space-y-5">
<!-- Seed Input -->
<div class="mb-1">
<label class="block mb-1.5 text-sm font-medium text-slate-700">种子</label>
<div class="flex items-center space-x-2">
<el-input v-model="seed" placeholder="随机种子" class="flex-grow"></el-input>
<el-button :icon="RefreshRight" @click="randomizeSeed" class="flex-shrink-0 hover:bg-blue-50"></el-button>
</div>
</div>
<!-- Negative Prompts -->
<div class="mb-1">
<label class="block mb-1.5 text-sm font-medium text-slate-700">反向提示词</label>
<el-input type="textarea" :rows="3" v-model="negativePrompt" placeholder="不想在视频中看到的内容" class="w-full"></el-input>
</div>
<!-- Debug Mode -->
<div class="px-3 py-1 rounded-lg bg-slate-50">
<div class="flex justify-between items-center">
<label class="text-sm font-medium text-slate-700">调试模式</label>
<el-switch v-model="debugMode" />
</div>
<p class="mt-1 text-xs text-slate-500">开启后将使用本地测试数据不会调用真实API</p>
</div>
<!-- Reference Image Upload -->
<div class="p-3 mt-4 mb-1 rounded-lg bg-slate-50">
<div class="flex justify-between items-center mb-2">
<label class="text-sm font-medium text-slate-700">参考图片</label>
<el-upload
class="flex justify-start"
:auto-upload="false"
:limit="1"
:on-change="handleImageUpload"
:on-remove="handleImageRemove"
accept="image/*"
>
<template #trigger>
<div class="flex">
<el-button type="primary" size="small" class="text-xs">选择图片</el-button>
</div>
</template>
</el-upload>
</div>
<div v-if="uploadedImageBase64" class="flex justify-center mt-2">
<div class="overflow-hidden relative w-full max-h-40 rounded-md">
<img :src="uploadedImageBase64" class="object-contain w-full" alt="参考图片" />
</div>
</div>
</div>
</div>
</div>
<!-- Middle Panel: Video Display & Prompt Input -->
<div class="flex flex-col flex-grow p-5 bg-white">
<!-- Video Display Area -->
<div class="flex overflow-hidden relative flex-col flex-grow mb-6 rounded-xl shadow-inner bg-slate-100">
<div v-if="generatedVideoUrl" class="w-full h-full">
<div class="flex justify-center items-center p-4 w-full h-full">
<video
controls
autoplay
class="object-contain max-w-full max-h-full rounded-lg shadow-md"
:src="baseURL + generatedVideoUrl"
:poster="posterUrl"
></video>
</div>
</div>
<div v-else-if="isGenerating" class="flex flex-col justify-center items-center h-full">
<el-icon class="text-blue-500 animate-spin" size="48"><Loading /></el-icon>
<span class="px-8 mt-4 text-center text-slate-600">{{ generatingMessage }}</span>
</div>
<div v-else class="flex justify-center items-center h-full">
<div class="text-center">
<el-icon class="mb-2 text-slate-400" size="48"><VideoCamera /></el-icon>
<p class="text-slate-500">视频将在这里显示</p>
</div>
</div>
</div>
<!-- Example Prompts -->
<div class="mb-5">
<div class="flex justify-between items-center mb-2">
<h3 class="flex items-center text-sm font-medium text-slate-700">
示例提示词
<el-tooltip content="点击使用示例提示词" placement="top">
<el-icon class="ml-1 text-slate-400"><InfoFilled /></el-icon>
</el-tooltip>
</h3>
</div>
<div class="grid grid-cols-1 gap-2 pb-1">
<div
v-for="example in examplePrompts"
:key="example"
@click="prompt = example"
class="flex items-center p-2.5 bg-white rounded-lg border transition-colors cursor-pointer border-slate-200 hover:bg-blue-50 hover:border-blue-300"
>
<el-icon class="mr-2 text-blue-500"><VideoPlay /></el-icon>
<div class="overflow-hidden">
<p class="text-sm truncate">{{ truncateText(example, 40) }}</p>
</div>
</div>
</div>
</div>
<!-- Prompt Input Area -->
<div class="flex items-center p-3 bg-white rounded-xl border shadow-sm border-slate-200">
<el-input
v-model="prompt"
placeholder="请输入你的视频创意指令... "
class="flex-grow mr-3 prompt-input"
@keyup.enter.prevent="handlePromptEnter"
autosize
type="textarea"
:maxlength="500"
:input-style="{ border: 'none', boxShadow: 'none', padding: '8px 0px', lineHeight: '1.5', resize: 'none' }"
resize="none"
>
</el-input>
<el-button
circle
type="primary"
@click="generateVideo"
:loading="isGenerating"
class="flex justify-center items-center w-12 h-12 min-w-[3rem] min-h-[3rem] rounded-full shadow-md transition-all hover:shadow-lg transform hover:scale-105"
:disabled="!prompt.trim() || !uploadedImageBase64"
>
<el-icon size="24"><VideoCamera /></el-icon>
</el-button>
</div>
</div>
<!-- Right Panel: History -->
<div class="w-1/4 min-w-[280px] bg-white border-l border-slate-200">
<HistoryPanel
ref="historyPanelRef"
material-type="Video"
thumbnailAlt="Video thumbnail"
@item-click="loadHistoryVideo"
:show-time-column="true"
:show-status-column="true"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onUnmounted } from 'vue';
import { RefreshRight, Loading, InfoFilled, VideoPlay, VideoCamera } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import ModelList from './components/widgets/modelList.vue';
import HistoryPanel, { type HistoryItem } from './components/widgets/historyPanel.vue';
import { generateVideoCompletion, getVideoCompletionStatus, type VideoCompletionParams } from '/@/api/knowledge/aiGen';
import { Local } from '/@/utils/storage';
import { useIntervalFn } from '@vueuse/core';
// Import SVG icons
import imageSize11 from '/@/assets/ai/images/image-size-1-1.svg';
import imageSize169 from '/@/assets/ai/images/image-size-16-9.svg';
import imageSize916 from '/@/assets/ai/images/image-size-9-16.svg';
// SVG icon mapping
const iconMap: Record<string, string> = {
'image-size-1-1': imageSize11,
'image-size-16-9': imageSize169,
'image-size-9-16': imageSize916,
};
// 获取图标URL的辅助函数
const getIconUrl = (iconName: string) => {
return iconMap[iconName] || '';
};
// 文本截断的辅助函数
const truncateText = (text: string, maxLength: number) => {
return text?.length > maxLength ? text.substring(0, maxLength) + '...' : text || '';
};
// 左侧面板: 控制数据
const modelListComponent = ref<any>(null); // 模型选择组件引用
const imageSizes = ref([
{ label: '16:9', value: '1280x720', iconName: 'image-size-16-9' }, // 标准视频比例
{ label: '9:16', value: '720x1280', iconName: 'image-size-9-16' }, // 手机竖屏视频
{ label: '1:1', value: '960x960', iconName: 'image-size-1-1' }, // 方形视频
]);
const selectedimageSize = ref('1280x720'); // 默认视频尺寸为16:9
const seed = ref(''); // 随机种子值
const negativePrompt = ref<string | null>(null); // 反向提示词,指定不希望在视频中出现的内容
const uploadedImageBase64 = ref<string | null>(null); // 上传图片的Base64编码
const uploadedImageFile = ref<File | null>(null); // 上传的图片文件对象
const debugMode = ref(true); // 调试模式开关,默认关闭
// 右侧面板: 数据
const generatedVideoUrl = ref<string>(''); // 当前显示的生成视频URL
const posterUrl = ref<string>(''); // 视频封面图URL
const historyPanelRef = ref<InstanceType<typeof HistoryPanel> | null>(null); // 历史面板组件引用
const prompt = ref(''); // 提示词输入
const isGenerating = ref(false); // 是否正在生成视频
const generatingMessage = ref('正在生成视频...'); // 生成过程中的提示消息
const progress = ref(0); // 视频生成进度
const currentTaskId = ref<string | null>(null); // 当前任务ID
const pollAttempts = ref(0); // 轮询尝试次数计数器
// 处理图片上传
const handleImageUpload = (file: any) => {
if (file.raw && file.raw.type && file.raw.type.startsWith('image/')) {
uploadedImageFile.value = file.raw;
const reader = new FileReader();
reader.onload = (e) => {
if (e.target && e.target.result) {
uploadedImageBase64.value = e.target.result.toString(); // 转换为Base64
}
};
reader.readAsDataURL(file.raw);
return false; // 阻止自动上传
}
ElMessage.error('请上传有效的图片文件');
return false;
};
// 处理图片移除
const handleImageRemove = () => {
uploadedImageFile.value = null;
uploadedImageBase64.value = null;
};
const examplePrompts = ref([
// 示例提示词列表
'一只小猫在草地上奔跑玩耍,阳光明媚,自然环境',
'城市夜景,街道上的车流形成光线轨迹,摩天大楼灯光闪烁',
'海洋波浪拍打海岸,日落时分,金色阳光洒在水面上',
'宇宙行星环绕恒星旋转,星云闪烁,史诗宇宙场景',
'瀑布从高山流下,四周是郁郁葱葱的森林,鸟儿飞过',
]);
// 左侧面板: 方法
// 生成随机种子
const randomizeSeed = () => {
seed.value = String(Math.floor(Math.random() * 100000));
};
// 右侧面板: 方法
// 处理回车键按下事件
const handlePromptEnter = (event: KeyboardEvent) => {
if (event.shiftKey) {
// 允许Shift+Enter换行
return;
}
generateVideo(); // 直接回车则生成视频
};
// 轮询函数 - 使用VueUse的useIntervalFn
const { pause, resume } = useIntervalFn(
async () => {
try {
const selectedModel = Local.get(`selectedAiModel:Video`);
const { data } = await getVideoCompletionStatus(currentTaskId.value!, selectedModel.name);
if (data) {
if (data.status === 'Failed') {
const reason = data.reason ? `: ${data.reason}` : '';
ElMessage.error(`视频生成失败${reason}`);
stopPolling();
isGenerating.value = false;
} else if (data.status === 'Succeed') {
if (data.results && data.results.videos && data.results.videos.length > 0) {
const firstVideo = data.results.videos[0];
generatedVideoUrl.value = firstVideo.url;
posterUrl.value = firstVideo.thumbnailUrl || '';
// 刷新服务器历史数据
historyPanelRef.value?.refreshHistory();
ElMessage.success('视频生成成功!');
} else {
ElMessage.error('视频生成成功,但未找到视频数据。');
}
stopPolling();
isGenerating.value = false;
} else if (data.status === 'InProgress') {
generatingMessage.value = `视频生成预计10分钟内返回结果可关闭此页面再右侧历史生成中双击查看结果`;
} else {
generatingMessage.value = `视频生成预计10分钟内返回结果可关闭此页面再右侧历史生成中双击查看结果`;
}
}
} catch (error) {
ElMessage.error(`轮询视频状态出错: ${error instanceof Error ? error.message : '未知错误'}`);
}
pollAttempts.value++;
if (pollAttempts.value >= 30) {
ElMessage.warning('视频生成时间过长,已停止等待');
stopPolling();
isGenerating.value = false;
}
},
5000,
{ immediate: false }
);
// 停止轮询
const stopPolling = () => {
pause();
pollAttempts.value = 0;
};
// 生成视频的核心方法
const generateVideo = async () => {
if (!prompt.value.trim()) {
ElMessage.warning('请输入提示词!');
return;
}
if (!uploadedImageBase64.value) {
ElMessage.warning('请上传参考图片!');
return;
}
if (isGenerating.value) {
ElMessage.warning('视频正在生成中,请稍候...');
return;
}
isGenerating.value = true;
generatedVideoUrl.value = '';
generatingMessage.value = '视频生成预计10分钟内返回结果可关闭此页面再右侧历史生成中双击查看结果';
progress.value = 0;
pollAttempts.value = 0;
try {
const selectedModel = Local.get(`selectedAiModel:Video`);
const params: VideoCompletionParams = {
model: selectedModel.name,
imageSize: selectedimageSize.value,
seed: seed.value ? Number(seed.value) : undefined,
negativePrompt: negativePrompt.value || undefined,
prompt: prompt.value,
image: uploadedImageBase64.value || undefined,
};
if (debugMode.value) {
// 调试模式使用固定的TaskID不调用实际API
currentTaskId.value = 'to9j43xb98j9';
generatingMessage.value = '视频正在生成中,请稍候...';
} else {
// 生产模式调用实际API
const { data } = await generateVideoCompletion(params);
if (data) {
currentTaskId.value = data;
generatingMessage.value = '视频正在生成中,请稍候...';
// 开始轮询检查视频生成状态
resume();
} else {
ElMessage.error('提交视频生成请求失败');
isGenerating.value = false;
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ElMessage.error(`提交视频生成请求失败: ${errorMessage}`);
isGenerating.value = false;
}
};
// 从历史记录中加载视频
const loadHistoryVideo = (video: HistoryItem) => {
generatedVideoUrl.value = video.localUrl || video.url;
posterUrl.value = video.thumbnail;
prompt.value = video.prompt || '';
};
// 组件销毁前清除轮询定时器
onUnmounted(() => {
stopPolling();
});
// 组件初始化时生成随机种子
randomizeSeed();
</script>

View File

@@ -0,0 +1,309 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<div class="flex h-screen bg-gray-50">
<!-- Left Panel: Controls -->
<div class="flex-none w-1/4 min-w-[280px] mr-4 overflow-y-auto">
<div class="p-5 h-full bg-white rounded-xl shadow-sm">
<h3 class="mb-6 text-lg font-medium text-gray-800">语音控制</h3>
<!-- Model Selection -->
<div class="mb-6">
<div class="flex justify-between items-center">
<label class="text-sm font-medium text-gray-700">模型</label>
<div class="flex-grow ml-4">
<ModelList ref="modelListComponent" :model-type="['Voice']" />
</div>
</div>
</div>
<div class="space-y-6">
<!-- Speech Rate Slider -->
<div>
<div class="flex justify-between mb-2">
<label class="text-sm font-medium text-gray-700">倍速</label>
<span class="text-sm font-medium text-primary">{{ speechRate }}x</span>
</div>
<el-slider v-model="speechRate" :min="0.25" :max="4.0" :step="0.1" class="mx-1"></el-slider>
</div>
<!-- Volume Gain Slider -->
<div>
<div class="flex justify-between mb-2">
<label class="text-sm font-medium text-gray-700">音量增益 (dB)</label>
<span class="text-sm font-medium text-primary">{{ volumeGain }}</span>
</div>
<el-slider v-model="volumeGain" :min="-10" :max="10" :step="1" class="mx-1"></el-slider>
</div>
<!-- Voice Selection -->
<div>
<label class="block mb-2 text-sm font-medium text-gray-700">音色</label>
<el-select v-model="selectedVoice" placeholder="请选择音色" class="w-full">
<el-option v-for="voice in voices" :key="voice.value" :label="voice.label" :value="voice.value" />
</el-select>
</div>
</div>
</div>
</div>
<!-- Middle Panel: Audio Player & Prompt Input -->
<div class="flex-1 mr-4">
<div class="flex flex-col p-5 h-full bg-white rounded-xl shadow-sm">
<h3 class="mb-6 text-lg font-medium text-gray-800">音频生成</h3>
<!-- Audio Player Area -->
<div class="mb-6 w-full">
<div v-if="generatedAudioUrl || selectedHistoryAudioUrl" class="flex flex-col">
<div class="flex items-center mb-4">
<div class="flex flex-shrink-0 justify-center items-center mr-4 w-12 h-12 rounded-full shadow-sm bg-primary">
<el-icon :size="24" class="text-white"><Headset /></el-icon>
</div>
<div class="overflow-hidden flex-1">
<h3 class="text-base font-medium text-gray-800 truncate">{{ prompt || 'Untitled Audio' }}</h3>
<div v-if="selectedHistoryAudioUrl" class="flex items-center mt-1 text-xs text-gray-500">
<el-icon class="mr-1" :size="12"><Timer /></el-icon>
<span>历史记录</span>
</div>
</div>
<el-button
v-if="selectedHistoryAudioUrl"
class="ml-2"
size="small"
type="default"
@click="
selectedHistoryAudioUrl = null;
selectedHistoryPrompt = null;
"
icon="Close"
></el-button>
</div>
<div class="p-4 bg-gray-50 rounded-xl border border-gray-100 audio-container">
<audio controls :src="baseURL + (selectedHistoryAudioUrl || generatedAudioUrl)" class="w-full rounded audio-controls">
Your browser does not support the audio element.
</audio>
</div>
</div>
<div v-else-if="isGenerating" class="flex justify-center items-center h-[160px] bg-gray-50 rounded-xl border border-gray-100">
<div class="flex flex-col items-center text-primary">
<el-icon class="mb-3 animate-spin" :size="28"><Loading /></el-icon>
<span class="text-gray-600">正在生成音频...</span>
</div>
</div>
<div v-else class="flex justify-center items-center h-[160px] bg-gray-50 rounded-xl border border-dashed border-gray-200">
<div class="flex flex-col items-center text-gray-400">
<el-icon class="mb-3" :size="28"><VideoCameraFilled /></el-icon>
<span>音频将在这里播放</span>
</div>
</div>
</div>
<!-- Example Prompts -->
<div v-if="!selectedHistoryAudioUrl" class="mb-6">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-medium text-gray-700">示例描述</h3>
<el-tooltip content="点击使用示例描述" placement="top">
<el-icon class="text-gray-400 cursor-help"><InfoFilled /></el-icon>
</el-tooltip>
</div>
<div class="grid overflow-y-auto grid-cols-2 gap-2 pb-1 max-h-36">
<div
v-for="example in examplePrompts"
:key="example.text"
@click="prompt = example.text"
class="flex items-center p-3 bg-gray-50 rounded-lg border border-gray-200 transition-all duration-200 cursor-pointer hover:bg-blue-50 hover:border-blue-300"
>
<el-icon class="flex-shrink-0 mr-2 text-blue-500"><ChatLineRound /></el-icon>
<p class="text-sm truncate">{{ truncateText(example.text, 60) }}</p>
</div>
</div>
</div>
<!-- Prompt Input Area -->
<div class="mt-auto">
<div v-if="selectedHistoryAudioUrl" class="mb-3 w-full text-center">
<div class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-full">
<el-icon class="mr-1.5"><InfoFilled /></el-icon>您正在收听历史音频可以编辑描述重新生成
</div>
</div>
<div class="flex items-center p-4 w-full bg-gray-50 rounded-xl border border-gray-200">
<el-input
v-model="prompt"
placeholder="请输入你的音频创作指令... "
class="flex-grow mr-4"
@keyup.enter.prevent="handlePromptEnter"
type="textarea"
autosize
:maxlength="500"
:input-style="{
border: 'none',
boxShadow: 'none',
padding: '8px 0',
lineHeight: '1.5',
resize: 'none',
backgroundColor: selectedHistoryAudioUrl ? '#f9f9f9' : 'transparent',
}"
/>
<button
@click="generateAudio"
:disabled="!prompt.trim() || isGenerating"
class="flex flex-shrink-0 justify-center items-center w-14 h-14 text-white rounded-full shadow transition-all duration-200 bg-primary disabled:opacity-50 disabled:cursor-not-allowed hover:shadow-md hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
<el-icon :size="24" v-if="isGenerating"><Loading /></el-icon>
<el-icon :size="24" v-else><Promotion /></el-icon>
</button>
</div>
</div>
</div>
</div>
<!-- Right Panel: History -->
<div class="flex-none w-1/4">
<div class="h-full bg-white rounded-xl shadow-sm">
<HistoryPanel
ref="historyPanelRef"
title="生成历史"
material-type="Voice"
@item-click="handleHistoryItemClick"
:default-page-size="pageSize"
:show-time-column="true"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Promotion, InfoFilled, ChatLineRound, Headset, VideoCameraFilled, Loading, Timer } from '@element-plus/icons-vue';
import { ElMessage, ElSelect, ElOption, ElSlider } from 'element-plus';
import ModelList from './components/widgets/modelList.vue';
import HistoryPanel, { type HistoryItem } from './components/widgets/historyPanel.vue';
import { genAudio } from '/@/api/knowledge/aiGen'; //需要创建或修改此API
import { Local } from '/@/utils/storage';
// 文本截断的辅助函数
const truncateText = (text: string, maxLength: number) => {
return text?.length > maxLength ? text.substring(0, maxLength) + '...' : text || '';
};
// 左侧面板: 控制数据
const modelListComponent = ref<any>(null); // 模型选择组件引用
const voices = ref([
{ label: 'Alex', value: 'alex' },
{ label: 'Anna', value: 'anna' },
{ label: 'Bella', value: 'bella' },
{ label: 'Benjamin', value: 'benjamin' },
{ label: 'Charles', value: 'charles' },
{ label: 'Claire', value: 'claire' },
{ label: 'David', value: 'david' },
{ label: 'Diana', value: 'diana' },
]);
const selectedVoice = ref('alex'); // 默认音色
const speechRate = ref(1.0); // 默认倍速
const volumeGain = ref(0.0); // 默认音量增益
// 中间面板: 数据
const generatedAudioUrl = ref<string | null>(null); // 当前生成的音频URL
const prompt = ref(''); // 提示词输入
const isGenerating = ref(false); // 是否正在生成音频
const selectedHistoryAudioUrl = ref<string | null>(null); // 选中的历史音频URL
const selectedHistoryPrompt = ref<string | null>(null); // 选中的历史音频的提示
const examplePrompts = ref([
{ text: '一段平静的钢琴曲,适合冥想。' },
{ text: '一个男人用深沉的嗓音说出:世界,你好!' },
{ text: '欢快的背景音乐,带有鸟鸣声。' },
{ text: '科幻电影中的飞船引擎启动声。' },
{ text: '暴风雨的声音,包括雷声和雨声。' },
{ text: '用女声朗读一首关于春天的短诗。' },
]);
// 历史记录和分页相关数据
const pageSize = ref(10);
// 中间面板: 方法
// 处理回车键按下事件
const handlePromptEnter = (event: KeyboardEvent) => {
if (event.shiftKey) {
// 允许Shift+Enter换行
return;
}
generateAudio(); // 直接回车则生成音频
};
// 历史面板引用
const historyPanelRef = ref<InstanceType<typeof HistoryPanel> | null>(null);
// 处理历史项目点击
const handleHistoryItemClick = (item: HistoryItem) => {
if (item.localUrl) {
// 显示历史音频
selectedHistoryAudioUrl.value = item.localUrl;
selectedHistoryPrompt.value = item.prompt || '历史音频';
// 应用提示词
prompt.value = item.prompt || '';
// 如果历史项包含音色等参数,也在此处恢复
// selectedVoice.value = item.params?.voice || 'alex';
// speechRate.value = item.params?.rate || 1.0;
} else {
ElMessage.info('该记录没有音频文件');
}
};
// 生成音频的核心方法
const generateAudioCore = async () => {
if (!prompt.value.trim()) {
ElMessage.warning('请输入音频描述!');
return;
}
isGenerating.value = true;
generatedAudioUrl.value = null; // 清除之前的音频
selectedHistoryAudioUrl.value = null; // 清除选中的历史音频
try {
// 获取本地存储的选中模型
const selectedModel = Local.get(`selectedAiModel:Voice`);
if (!selectedModel || !selectedModel.name) {
ElMessage.error('请先选择一个有效的AI模型');
isGenerating.value = false;
return;
}
// 准备API调用参数
const params = {
model: selectedModel.name, // 选中的AI模型名称
voice: selectedVoice.value, // 选择的音色
speed: speechRate.value, // 语速
gain: volumeGain.value, // 音量增益
input: prompt.value, // 文本提示
};
// 调用API生成音频
const { data: audioUrl } = await genAudio(params); // genAudio是假设的API方法
if (audioUrl) {
generatedAudioUrl.value = audioUrl;
ElMessage.success('音频生成成功!');
// 生成成功后刷新历史记录
historyPanelRef.value?.refreshHistory();
} else {
ElMessage.error('音频生成失败, 请稍后重试');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ElMessage.error(`生成音频时发生错误: ${errorMessage}`);
} finally {
isGenerating.value = false; // 无论成功或失败,都重置生成状态
}
};
// 对外暴露的生成音频方法(可能用于快捷键等)
const generateAudio = () => {
generateAudioCore();
};
</script>

View File

@@ -0,0 +1,334 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-row v-show="showSearch">
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
<el-form-item label="知识库名" prop="title">
<el-select class="!w-[150px]" placeholder="请选择知识库" v-model="state.queryForm.datasetId">
<el-option :key="index" :label="item.name" :value="item.id" v-for="(item, index) in datasetList">
{{ item.name }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="问题" prop="questionText">
<el-input placeholder="请输入问题" v-model="state.queryForm.questionText" />
</el-form-item>
<el-form-item label="标注" prop="standardFlag">
<el-select class="!w-[150px]" v-model="state.queryForm.standardFlag" placeholder="是否标注">
<el-option v-for="item in yes_no_type" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="调用结果" prop="llmFlag">
<el-select class="!w-[150px]" v-model="state.queryForm.llmFlag" placeholder="请选择交互结果">
<el-option v-for="item in llm_use_status" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="getDataList"> 查询 </el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row>
<div class="mb8" style="width: 100%">
<el-button plain :disabled="multiple" icon="Delete" type="primary" v-auth="'knowledge_aiChatRecord_del'" @click="handleDelete(selectObjs)">
删除
</el-button>
<right-toolbar
v-model:showSearch="showSearch"
:export="'knowledge_aiChatRecord_export'"
@exportExcel="exportExcel"
class="ml10 mr20"
style="float: right"
@queryTable="getDataList"
></right-toolbar>
</div>
</el-row>
<splitpanes>
<pane size="65">
<el-table
:data="state.dataList"
v-loading="state.loading"
border
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
@selection-change="selectionChangHandle"
@row-click="rowClickHandle"
@sort-change="sortChangeHandle"
>
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="50" />
<el-table-column width="150" prop="datasetName" label="所属知识库" show-overflow-tooltip />
<el-table-column width="100" prop="username" label="用户标识" show-overflow-tooltip />
<el-table-column prop="questionText" label="问题" show-overflow-tooltip />
<el-table-column width="100" prop="standardFlag" label="已标注" show-overflow-tooltip>
<template #default="scope">
<dict-tag :options="yes_no_type" :value="scope.row.standardFlag"></dict-tag>
</template>
</el-table-column>
<el-table-column width="100" prop="llmFlag" label="交互成功" show-overflow-tooltip>
<template #default="scope">
<dict-tag :options="llm_use_status" :value="scope.row.llmFlag"></dict-tag>
</template>
</el-table-column>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</pane>
<pane size="35">
<div class="flex items-center justify-center h-full pl-4 bg-slate-50/30">
<div class="mx-auto w-full max-w-[580px] h-full">
<div
v-if="!selectRow.recordId"
class="flex flex-col items-center justify-center h-full p-8 bg-white border shadow-lg rounded-2xl border-slate-200/60 shadow-slate-100/50 backdrop-blur-sm"
>
<div class="flex items-center justify-center w-16 h-16 mb-6 border bg-gradient-to-br from-indigo-50 to-blue-50 rounded-2xl border-indigo-100/50">
<svg class="w-8 h-8 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
<div class="text-center">
<h3 class="mb-2 text-lg font-semibold text-slate-700">请选择记录</h3>
<p class="text-sm text-slate-500">从左侧列表中选择一条对话记录查看详情</p>
</div>
</div>
<template v-else>
<div class="h-full overflow-hidden bg-white border shadow-lg rounded-2xl border-slate-200/60 shadow-slate-100/50 backdrop-blur-sm">
<!-- 顶部标注切换区域 -->
<div class="sticky top-0 z-20 flex items-center justify-between px-6 py-4 border-b bg-gradient-to-r from-blue-500/5 to-indigo-500/5 backdrop-blur-md border-blue-100/50" v-if="selectRow.llmFlag === '1'">
<div class="flex items-center space-x-3">
<div class="relative">
<div class="w-3 h-3 rounded-full bg-gradient-to-r from-blue-500 to-indigo-500 animate-pulse"></div>
<div class="absolute inset-0 w-3 h-3 rounded-full bg-gradient-to-r from-blue-500 to-indigo-500 animate-ping opacity-20"></div>
</div>
<span class="text-lg font-bold text-transparent bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text">智能标注</span>
</div>
<div class="flex items-center px-4 py-2 space-x-4 border rounded-full bg-white/60 border-blue-100/50 backdrop-blur-sm">
<label class="text-sm font-medium cursor-pointer select-none text-slate-700">标注为正确答案</label>
<el-switch
v-model="selectRow.standardFlag"
@change="editHandle"
:active-value="'1'"
:inactive-value="'0'"
class="scale-110 drop-shadow-sm"
></el-switch>
</div>
</div>
<!-- 内容区域 -->
<div class="h-full overflow-auto">
<div class="p-2 space-y-8">
<!-- 用户提问区域 -->
<div class="relative group">
<div v-if="selectRow.llmFlag === '2'" class="relative">
<div class="p-6 transition-all duration-300 border shadow-sm bg-gradient-to-br from-white to-slate-50/30 rounded-2xl border-slate-200/50 hover:shadow-md hover:border-slate-300/50">
<div class="leading-relaxed prose-sm prose max-w-none text-slate-700" v-html="matchResult" @click="handleChildClick" />
</div>
</div>
<div v-else class="relative">
<div class="p-6 transition-all duration-300 border shadow-sm bg-gradient-to-br from-blue-50/30 to-indigo-50/30 rounded-2xl border-blue-200/50 hover:shadow-md hover:border-blue-300/50 hover:bg-gradient-to-br hover:from-blue-50/50 hover:to-indigo-50/50">
<div class="leading-relaxed prose-sm prose max-w-none text-slate-800 font-medium">
{{ selectRow.questionText }}
</div>
</div>
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div class="flex items-center px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50/80 rounded-full border border-blue-200/50 backdrop-blur-sm">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
问题
</div>
</div>
</div>
</div>
<!-- 大模型答案区域 -->
<div class="relative group">
<div class="flex items-center mb-5 space-x-3">
<div class="relative">
<div class="flex items-center justify-center w-10 h-10 border shadow-sm bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl border-blue-100/50">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
</svg>
</div>
<div class="absolute w-3 h-3 border-2 border-white rounded-full shadow-sm -top-1 -right-1 bg-blue-400"></div>
</div>
<div class="flex items-center space-x-3">
<div>
<h3 class="text-lg font-bold text-slate-800">AI 智能回答</h3>
</div>
<div class="flex items-center px-3 py-1 border rounded-full bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-100/50">
<svg class="w-3 h-3 text-blue-500 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-xs font-medium text-blue-600">支持Markdown</span>
</div>
</div>
</div>
<div class="overflow-hidden transition-all duration-300 border shadow-sm bg-gradient-to-br from-white to-slate-50/30 rounded-2xl border-slate-200/50 hover:shadow-md hover:border-slate-300/50">
<ai-editor
v-model="selectRow.answerText"
output="text"
:hide-menubar="true"
:hide-toolbar="true"
placeholder="AI回答将在此处显示支持Markdown格式..."
:minHeight="480"
class="bg-transparent border-0"
/>
</div>
</div>
</div>
<!-- 底部渐变遮罩 -->
<div class="sticky bottom-0 h-6 pointer-events-none bg-gradient-to-t from-white via-white/80 to-transparent"></div>
</div>
</div>
</template>
</div>
</div>
</pane>
</splitpanes>
</div>
</div>
</template>
<script setup lang="ts" name="systemAiChatRecord">
import { BasicTableProps, useTable } from '/@/hooks/table';
import { fetchList, delObjs, putObj } from '/@/api/knowledge/aiChatRecord';
import { useMessage, useMessageBox } from '/@/hooks/message';
import { useDict } from '/@/hooks/dict';
import { fetchDataList } from '/@/api/knowledge/aiDataset';
import { addObj, getObj, testObj } from '/@/api/admin/sensitive';
import AiEditor from '/@/components/AiEditor/index.vue';
// 定义查询字典
const { yes_no_type, llm_use_status } = useDict('yes_no_type', 'llm_use_status');
// 定义变量内容
const matchResult = ref();
const selectRow = reactive({
recordId: '',
answerText: '',
questionText: '',
standardFlag: 0,
qdrantId: '',
llmFlag: '' as '0' | '1' | '2',
});
// 搜索变量
const queryRef = ref();
const showSearch = ref(true);
// 多选变量
const selectObjs = ref([]) as any;
const multiple = ref(true);
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {},
pageList: fetchList,
descs: ['create_time'],
});
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state);
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
queryRef.value?.resetFields();
// 清空多选
selectObjs.value = [];
getDataList();
};
// 导出excel
const exportExcel = () => {
downBlobFile('/knowledge/aiChatRecord/export', Object.assign(state.queryForm, { ids: selectObjs }), 'aiChatRecord.xlsx');
};
// 多选事件
const selectionChangHandle = (objs: { recordId: string }[]) => {
selectObjs.value = objs.map(({ recordId }) => recordId);
multiple.value = !objs.length;
};
const datasetList = ref<Array<{ id: string; name: string }>>([]);
const getDatasetList = async () => {
const { data } = await fetchDataList();
datasetList.value = data;
};
// 删除操作
const handleDelete = async (ids: string[]) => {
try {
await useMessageBox().confirm('此操作将永久删除');
} catch {
return;
}
try {
await delObjs(ids);
getDataList();
useMessage().success('删除成功');
} catch (err: any) {
getDataList();
useMessage().error(err.msg);
}
};
// 表格行点击
const rowClickHandle = async (row: any) => {
Object.assign(selectRow, row);
if (row.llmFlag === '2') {
const { data } = await testObj({ sensitiveWord: row.questionText });
// 要处理的字符串
matchResult.value = row.questionText;
// 遍历关键词数组,并进行替换
data.forEach((word: string) => {
let regex = new RegExp(word, 'g');
matchResult.value = matchResult.value.replace(
regex,
`
<div class="tooltip tooltip-open tooltip-bottom" data-tip="加入白名单">
<a class="link link-error" @click="$emit('click-child')">${word}</a>
</div>
`
);
});
}
};
const editHandle = async () => {
putObj(selectRow)
.then(() => {
getDataList();
useMessage().success('操作成功');
getDataList();
})
.catch((err: any) => {
useMessage().error(err.msg);
});
};
const handleChildClick = async (event: any) => {
try {
if (event.target.tagName.toLowerCase() === 'a' && event.target.classList.contains('link-error')) {
const { data } = await getObj({ sensitiveWord: event.target.innerText, sensitiveType: '1' });
if (data) {
useMessage().error('数据已存在,请勿重新添加');
return;
}
await addObj({ sensitiveWord: event.target.innerText, sensitiveType: '1' });
useMessage().success('白名单添加成功');
}
} catch (err: any) {
useMessage().error(err.msg);
}
};
onMounted(() => {
getDatasetList();
});
</script>

View File

@@ -0,0 +1,181 @@
<template>
<el-dialog :title="form.dataId ? '编辑' : '新增'" v-model="visible" :close-on-click-modal="false" :width="600" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="90px" v-loading="loading">
<el-form-item label="名称" prop="datasetName">
<el-input v-model="form.datasetName" placeholder="请输入数据集名称" />
</el-form-item>
<el-form-item prop="description">
<template #label> 描述<tip content="非常重要在多个数据集之间,请输入清晰的描述信息,方便大模型自动区分" /> </template>
<el-input type="textarea" rows="4" v-model="form.description" placeholder="请输入数据集描述" />
</el-form-item>
<el-form-item label="数据源" prop="dsName">
<el-select v-model="form.dsName" placeholder="请选择数据源" @change="handleDsNameChange">
<el-option label="默认数据源" value="master"></el-option>
<el-option v-for="ds in datasourceList" :key="ds" :label="ds" :value="ds"></el-option>
</el-select>
</el-form-item>
<el-form-item label="数据表" prop="tableName">
<el-select
v-model="form.tableName"
placeholder="请选择数据表"
multiple
filterable
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="3"
style="width: 100%"
>
<el-option :key="table" :label="table" :value="table" v-for="table in tableList"></el-option>
</el-select>
</el-form-item>
<el-form-item label="学习状态" prop="learningStatus">
<el-radio-group v-model="form.learningStatus">
<el-radio :label="item.value" v-for="(item, index) in yes_no_type" border :key="index">{{ item.label }} </el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="AiDataDialog">
import { useDict } from '/@/hooks/dict';
import { useMessage } from '/@/hooks/message';
import { getObj, addObj, putObj } from '/@/api/knowledge/aiData';
import { list } from '/@/api/gen/datasource';
import { listTables } from '/@/api/knowledge/aiDataTable';
const emit = defineEmits(['refresh']);
// 定义变量内容
const dataFormRef = ref();
const visible = ref(false);
const loading = ref(false);
// 定义字典
const { yes_no_type } = useDict('yes_no_type');
// 数据源列表
const datasourceList = ref<string[]>([]);
// 数据表列表
const tableList = ref<string[]>([]);
// 提交表单数据
const form = reactive({
dataId: '',
datasetName: '',
description: '',
datasetType: '',
dsName: '',
learningStatus: '0',
tableName: [],
});
// 定义校验规则
const dataRules = ref({
datasetName: [
{ required: true, message: '数据集名称不能为空', trigger: 'blur' },
{ max: 20, message: '数据集名称不能超过20个字符', trigger: 'blur' },
],
description: [
{ required: true, message: '数据集描述不能为空', trigger: 'blur' },
{ max: 200, message: '数据集描述不能超过200个字符', trigger: 'blur' },
],
dsName: [{ required: true, message: '关联数据源不能为空', trigger: 'blur' }],
tableName: [{ required: true, message: '关联数据表不能为空', trigger: 'blur' }],
learningStatus: [{ required: true, message: '学习状态不能为空', trigger: 'blur' }],
});
// 打开弹窗
const openDialog = (id: string) => {
visible.value = true;
form.dataId = '';
// 重置表单数据
nextTick(() => {
dataFormRef.value?.resetFields();
});
// 加载数据源列表
loadDatasourceList();
// 获取aiData信息
if (id) {
form.dataId = id;
getaiDataData(id);
}
};
// 提交
const onSubmit = async () => {
const valid = await dataFormRef.value.validate().catch(() => {});
if (!valid) return false;
try {
loading.value = true;
form.dataId ? await putObj(form) : await addObj(form);
useMessage().success(form.dataId ? '修改成功' : '添加成功');
visible.value = false;
emit('refresh');
} catch (err: any) {
useMessage().error(err.msg);
} finally {
loading.value = false;
}
};
// 初始化表单数据
const getaiDataData = (id: string) => {
// 获取数据
loading.value = true;
getObj(id)
.then((res: any) => {
Object.assign(form, res.data);
if (form.dsName) {
loadTableList(form.dsName);
}
})
.finally(() => {
loading.value = false;
});
};
// 加载数据源列表
const loadDatasourceList = async () => {
try {
const { data } = await list();
datasourceList.value = data?.map((item: any) => item.name) || [];
} catch (error) {
useMessage().error('加载数据源列表失败');
}
};
// 加载数据表列表
const loadTableList = async (dsName: string) => {
if (!dsName) return;
try {
const { data } = await listTables(dsName);
tableList.value = data.map((item: any) => item.tableName) || [];
} catch (error) {
useMessage().error('加载数据表列表失败');
}
};
// 数据源变更处理
const handleDsNameChange = (value: string) => {
form.tableName = []; // 清空表名
loadTableList(value);
};
// 暴露变量
defineExpose({
openDialog,
});
</script>

View File

@@ -0,0 +1,267 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-row v-show="showSearch">
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
<el-form-item label="数据集名称" prop="datasetName">
<el-input placeholder="请输入数据集名称" v-model="state.queryForm.datasetName" />
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="getDataList"> 查询 </el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="formDialogRef.openDialog()" v-auth="'knowledge_aiData_add'">
</el-button>
<el-button plain :disabled="multiple" icon="Delete" type="primary" v-auth="'knowledge_aiData_del'" @click="handleDelete(selectObjs)">
删除
</el-button>
<right-toolbar
v-model:showSearch="showSearch"
:export="'knowledge_aiData_export'"
@exportExcel="exportExcel"
class="ml10 mr20"
style="float: right"
@queryTable="getDataList"
></right-toolbar>
</div>
</el-row>
<el-scrollbar class="h-[calc(100vh-280px)] mb-4">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div
v-for="item in state.dataList"
:key="item.dataId"
class="group overflow-hidden bg-white rounded-lg shadow-sm border border-gray-100 transition-all duration-300 cursor-pointer dark:bg-gray-800 dark:border-gray-700 hover:shadow-lg hover:border-primary-100 hover:translate-y-[-2px]"
@click="handleNavigateToChat(item)"
>
<div class="p-5">
<div class="flex items-start">
<div
class="flex justify-center items-center text-lg font-medium text-white bg-indigo-600 rounded-lg transition-transform size-12 group-hover:scale-110"
>
{{ item.datasetName ? item.datasetName.substring(0, 1).toUpperCase() : '' }}
</div>
<div class="overflow-hidden flex-1 ml-3">
<div class="text-base font-medium text-gray-900 truncate dark:text-white">
{{ item.datasetName }}
</div>
<div class="flex items-center mt-1 text-xs text-gray-500 dark:text-gray-400">
<el-icon class="mr-1"><User /></el-icon>
{{ item.dsName || '暂无' }}
</div>
</div>
<div>
<dict-tag :options="yes_no_type" :value="item.learningStatus"></dict-tag>
</div>
</div>
<div class="overflow-y-auto mt-4 h-16 text-sm text-gray-600 dark:text-gray-300 line-clamp-3">
{{ item.description || '暂无描述' }}
</div>
<div class="flex justify-start items-center pt-3 mt-4 border-t border-gray-100 dark:border-gray-700" @click.stop>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
v-auth="'knowledge_aiData_edit'"
@click="formDialogRef.openDialog(item.dataId)"
>
<el-icon><EditPen /></el-icon>
</el-button>
<div class="mx-2 w-px h-4 bg-gray-200 dark:bg-gray-700"></div>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
v-auth="'knowledge_aiData_del'"
@click="handleDelete([item.dataId])"
>
<el-icon><Delete /></el-icon>
</el-button>
<div class="mx-2 w-px h-4 bg-gray-200 dark:bg-gray-700"></div>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
@click="handleFieldEvaluation(item)"
>
<el-icon><Menu /></el-icon>
</el-button>
<div class="mx-2 w-px h-4 bg-gray-200 dark:bg-gray-700"></div>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
@click="handleNavigateToChat(item)"
>
<el-icon><ChatDotRound /></el-icon>
</el-button>
<div class="flex-grow ml-4"></div>
<div class="text-xs text-gray-400 dark:text-gray-500">
<el-icon class="mr-1"><Clock /></el-icon>
{{ parseDate(item.createTime) }}
</div>
<el-checkbox
class="ml-4"
:value="selectObjs.includes(item.dataId)"
@change="(val: boolean) => handleCardSelect(val, item.dataId)"
></el-checkbox>
</div>
</div>
</div>
</div>
</el-scrollbar>
<!-- 无数据显示 -->
<el-empty v-if="!state.dataList || state.dataList.length === 0" description="暂无数据"></el-empty>
<!-- 分页组件 -->
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</div>
<!-- 编辑新增 -->
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
<!-- 字段列表 -->
<field-dialog ref="fieldDialogRef" @refresh="getDataList(false)" />
</div>
</template>
<script setup lang="ts" name="systemAiData">
import { BasicTableProps, useTable } from '/@/hooks/table';
import { fetchList, delObjs } from '/@/api/knowledge/aiData';
import { useMessage, useMessageBox } from '/@/hooks/message';
import { useDict } from '/@/hooks/dict';
import { EditPen, Delete, User, ChatDotRound } from '@element-plus/icons-vue';
import { useRouter } from 'vue-router';
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
const FieldDialog = defineAsyncComponent(() => import('/@/views/knowledge/aiDataTable/field.vue'));
// 定义查询字典
const { yes_no_type } = useDict('yes_no_type');
// 定义变量内容
const formDialogRef = ref();
const fieldDialogRef = ref();
// 搜索变量
const queryRef = ref();
const showSearch = ref(true);
// 多选变量
const selectObjs = ref([]) as any;
const multiple = ref(true);
// 路由实例
const router = useRouter();
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {},
pageList: fetchList,
dataList: [],
});
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, downBlobFile } = useTable(state);
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
queryRef.value?.resetFields();
// 清空多选
selectObjs.value = [];
getDataList();
};
// 导出excel
const exportExcel = () => {
downBlobFile('/knowledge/aiData/export', Object.assign(state.queryForm, { ids: selectObjs }), 'aiData.xlsx');
};
// 多选事件 - 为卡片视图添加的选择函数
const handleCardSelect = (selected: boolean, dataId: string) => {
if (selected) {
selectObjs.value.push(dataId);
} else {
selectObjs.value = selectObjs.value.filter((id: string) => id !== dataId);
}
multiple.value = selectObjs.value.length === 0;
};
// 删除操作
const handleDelete = async (ids: string[]) => {
try {
await useMessageBox().confirm('此操作将永久删除');
} catch {
return;
}
try {
await delObjs(ids);
getDataList();
useMessage().success('删除成功');
} catch (err: any) {
useMessage().error(err.msg);
}
};
// 字段评估事件
const handleFieldEvaluation = (item: any) => {
if (item && item.dataId) {
// 获取数据源名称,如果不存在则设为空字符串
const dataSource = item.dsName || '';
// 确保表名数组是有效的数组
let tableNames = [];
if (item.tableName && Array.isArray(item.tableName)) {
tableNames = item.tableName;
}
// 打开字段评估对话框,传递数据源名称和表名数组
fieldDialogRef.value?.openDialog(dataSource, tableNames, false);
}
};
// 导航到聊天页面
const handleNavigateToChat = (item: any) => {
// 将所需参数传递给路由,并导航到 AI 聊天页面
router.push({
path: '/knowledge/aiChat/index',
query: {
datasetId: '-2', // 固定使用 Chat2BI 知识库ID
dataId: item.dataId,
},
});
};
</script>
<style lang="scss" scoped>
:deep(.el-scrollbar__wrap) {
overflow-x: hidden !important;
}
:deep(.el-checkbox) {
margin-right: 0;
}
.bg-primary-100 {
background-color: var(--el-color-primary-light-9);
}
.text-primary-500 {
color: var(--el-color-primary);
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<el-drawer title="字段评估" v-model="visible" size="100%" :close-on-click-modal="false" direction="rtl" @closed="onDrawerClosed">
<template #header>
<div class="drawer-header">
<span>字段评估</span>
</div>
</template>
<el-tabs v-model="activeTab" type="card" class="demo-tabs">
<el-tab-pane v-for="tableName in tableNames" :key="tableName" :label="tableName" :name="tableName">
<el-button plain type="primary" @click="handleSync(tableName)" :loading="syncLoading" class="mb-2">
<el-icon><Refresh /></el-icon> 同步
</el-button>
<el-button plain type="success" @click="handleAssess(tableName)" :loading="assessLoading" class="mb-2 ml-2">
<el-icon><Connection /></el-icon> 评估
</el-button>
<el-button plain type="info" @click="handleRefresh(tableName)" :loading="refreshLoading" class="mb-2 ml-2">
<el-icon><CircleCheck /></el-icon> 刷新
</el-button>
<sc-form-table ref="fieldTable" v-model="fieldLists[tableName]" :hideAdd="true" :hideDelete="true" drag-sort placeholder="暂无数据">
<el-table-column label="主键" prop="primaryPk" width="80" show-overflow-tooltip>
<template #default="{ row }">
<el-checkbox v-model="row.primaryPk" true-label="1" false-label="0" disabled></el-checkbox>
</template>
</el-table-column>
<el-table-column label="字段名称" prop="fieldName" width="150" show-overflow-tooltip></el-table-column>
<el-table-column label="字段备注" prop="fieldComment" show-overflow-tooltip></el-table-column>
<el-table-column label="虚拟备注" prop="virtualComment" show-overflow-tooltip>
<template #default="{ row }">
<el-input v-model="row.virtualComment" placeholder="请输入虚拟备注"></el-input>
</template>
</el-table-column>
<el-table-column label="字段类型" width="150" prop="fieldType" show-overflow-tooltip></el-table-column>
<el-table-column label="修正状态" width="150" prop="modifyStatus" show-overflow-tooltip>
<template #default="{ row }">
<dict-tag :options="yes_no_type" :value="row.modifyStatus"></dict-tag>
</template>
</el-table-column>
</sc-form-table>
</el-tab-pane>
</el-tabs>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading">保存</el-button>
</span>
</template>
</el-drawer>
</template>
<script setup lang="ts" name="AiDataTableFieldDialog">
import { fetchByTableId, syncByTableId, batchUpdateObj, assessByTableId } from '/@/api/knowledge/aiDataField';
import { getObj } from '/@/api/knowledge/aiData';
import { useMessage } from '/@/hooks/message';
import { useDict } from '/@/hooks/dict';
import Sortable from 'sortablejs';
import { Refresh, Connection, CircleCheck } from '@element-plus/icons-vue';
const emit = defineEmits(['refresh']);
// 引入组件
const scFormTable = defineAsyncComponent(() => import('/@/components/FormTable/index.vue'));
// 定义变量内容
const visible = ref(false);
const loading = ref(false);
const syncLoading = ref(false);
const assessLoading = ref(false);
const refreshLoading = ref(false);
const recordId = ref('');
const dsName = ref(''); // 添加数据源名称变量
const tableNames = ref<string[]>([]);
const activeTab = ref('');
const fieldLists = ref<{ [key: string]: any[] }>({});
const sortables = ref<{ [key: string]: any }>({});
const isDataTable = ref(false); // 标识是否是数据表视图
// 字典
const { yes_no_type } = useDict('yes_no_type');
// 启用行拖拽排序
const rowDrop = (tableName: string) => {
nextTick(() => {
const el: any = document.querySelector(`#tab-${tableName} .form-table`);
if (!el) return;
sortables.value[tableName] = Sortable.create(el.querySelector('.el-table__body-wrapper tbody'), {
handle: '.drag-btn',
onEnd: (e: any) => {
const { newIndex, oldIndex } = e;
const currRow = fieldLists.value[tableName].splice(oldIndex, 1)[0];
fieldLists.value[tableName].splice(newIndex, 0, currRow);
},
});
});
};
/**
* 打开弹窗
* @param dataSource 数据源名称
* @param tables 表名数组
* @param isTable 是否是数据表视图默认false表示是数据集视图
*/
const openDialog = async (dataSource: string, tables?: string[], isTable: boolean = false) => {
visible.value = true;
dsName.value = dataSource; // 保存数据源名称
recordId.value = dataSource; // 为了兼容原有逻辑也保存在recordId中
fieldLists.value = {};
isDataTable.value = isTable;
// 根据不同的模式处理表名
if (isTable) {
// 数据表模式
tableNames.value = tables && tables.length > 0 ? tables : [''];
} else {
// 数据集模式如果没有提供表名数组或为空数组则从API获取
if (!tables || tables.length === 0) {
try {
const res = await getObj(dataSource);
if (res.data && res.data.tableName && Array.isArray(res.data.tableName)) {
tableNames.value = res.data.tableName;
} else {
tableNames.value = [];
useMessage().warning('没有关联的数据表');
return;
}
} catch (err: any) {
useMessage().error(err.msg || '获取数据集信息失败');
tableNames.value = [];
return;
}
} else {
tableNames.value = tables;
}
}
// 设置激活的标签为第一个表(如果存在表的话)
if (tableNames.value.length > 0) {
activeTab.value = tableNames.value[0];
// 加载每个表的字段数据
tableNames.value.forEach((tableName) => {
getFieldList(dataSource, tableName);
});
}
};
// 同步字段
const handleSync = async (tableName: string) => {
if (!dsName.value || !tableName) return;
try {
syncLoading.value = true;
// 调用后台同步接口
await syncByTableId(dsName.value, tableName);
// 同步成功后重新获取字段列表
await getFieldList(dsName.value, tableName);
useMessage().success('同步成功');
} catch (err: any) {
useMessage().error(err.msg || '同步失败');
} finally {
syncLoading.value = false;
}
};
// 评估字段
const handleAssess = async (tableName: string) => {
if (!dsName.value || !tableName) return;
try {
assessLoading.value = true;
// 调用后台评估接口
await assessByTableId(dsName.value, tableName);
// 评估成功后重新获取字段列表
await getFieldList(dsName.value, tableName);
useMessage().success('字段正在评估中,请稍后刷新');
} catch (err: any) {
useMessage().error(err.msg || '评估失败');
} finally {
assessLoading.value = false;
}
};
// 刷新字段
const handleRefresh = async (tableName: string) => {
if (!dsName.value || !tableName) return;
try {
refreshLoading.value = true;
// 直接重新获取字段列表
await getFieldList(dsName.value, tableName);
useMessage().success('刷新成功');
} catch (err: any) {
useMessage().error(err.msg || '刷新失败');
} finally {
refreshLoading.value = false;
}
};
// 获取字段列表
const getFieldList = (dataSource: string, tableName: string) => {
loading.value = true;
return fetchByTableId(dataSource, tableName)
.then((res: any) => {
fieldLists.value[tableName] = res.data || [];
nextTick(() => {
rowDrop(tableName);
});
})
.catch((err: any) => {
useMessage().error(err.msg || '获取字段列表失败');
return Promise.reject(err);
})
.finally(() => {
loading.value = false;
});
};
// 关闭抽屉并触发刷新
const closeDrawer = () => {
visible.value = false;
emit('refresh');
};
// 提交表单
const onSubmit = async () => {
try {
loading.value = true;
// 使用批量更新接口更新所有表的字段
for (const tableName of tableNames.value) {
if (fieldLists.value[tableName] && fieldLists.value[tableName].length > 0) {
await batchUpdateObj(fieldLists.value[tableName]);
}
}
useMessage().success('保存成功');
closeDrawer();
} catch (err: any) {
useMessage().error(err.msg || '保存失败');
} finally {
loading.value = false;
}
};
// 当抽屉关闭时触发刷新事件
const onDrawerClosed = () => {
emit('refresh');
};
// 暴露变量
defineExpose({
openDialog,
closeDrawer,
});
</script>
<style lang="scss">
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.sortable-row-gen .drag-btn {
cursor: move;
font-size: 12px;
}
.sortable-row-gen .vxe-body--row.sortable-ghost,
.sortable-row-gen .vxe-body--row.sortable-chosen {
background-color: #dfecfb;
}
.demo-tabs > .el-tabs__content {
padding: 0 32px 32px 32px;
color: #6b778c;
font-size: 32px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<el-dialog :title="form.tableId ? '编辑' : '新增'" v-model="visible" :width="600" :close-on-click-modal="false" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="120px" v-loading="loading">
<el-form-item label="数据源" prop="dsName">
<el-input v-model="form.dsName" disabled placeholder="请输入关联数据源名称" />
</el-form-item>
<el-form-item label="表名称" prop="tableName">
<el-input v-model="form.tableName" disabled placeholder="请输入表名称" />
</el-form-item>
<el-form-item label="物理注释" prop="tableComment">
<el-input type="textarea" disabled rows="4" v-model="form.tableComment" placeholder="请输入物理表注释" />
</el-form-item>
<el-form-item label="逻辑注释" prop="virtualComment">
<el-input type="textarea" rows="4" v-model="form.virtualComment" placeholder="请输入逻辑表注释" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="AiDataTableDialog">
import { useMessage } from '/@/hooks/message';
import { getObj, addObj, putObj } from '/@/api/knowledge/aiDataTable';
const emit = defineEmits(['refresh']);
// 定义变量内容
const dataFormRef = ref();
const visible = ref(false);
const loading = ref(false);
// 定义字典
// 提交表单数据
const form = reactive({
tableId: '',
dsName: '',
tableName: '',
tableComment: '',
virtualComment: '',
});
// 定义校验规则
const dataRules = ref({
dsName: [{ required: true, message: '请输入关联数据源名称', trigger: 'blur' }],
tableName: [{ required: true, message: '请输入表名称', trigger: 'blur' }],
virtualComment: [{ required: true, message: '请输入虚拟表注释', trigger: 'blur' }],
});
// 打开弹窗
const openDialog = (id: string) => {
visible.value = true;
form.tableId = '';
// 重置表单数据
nextTick(() => {
dataFormRef.value?.resetFields();
});
// 获取aiDataTable信息
if (id) {
form.tableId = id;
getaiDataTableData(id);
}
};
// 提交
const onSubmit = async () => {
const valid = await dataFormRef.value.validate().catch(() => {});
if (!valid) return false;
try {
loading.value = true;
form.tableId ? await putObj(form) : await addObj(form);
useMessage().success(form.tableId ? '修改成功' : '添加成功');
visible.value = false;
emit('refresh');
} catch (err: any) {
useMessage().error(err.msg);
} finally {
loading.value = false;
}
};
// 初始化表单数据
const getaiDataTableData = (id: string) => {
// 获取数据
loading.value = true;
getObj(id)
.then((res: any) => {
Object.assign(form, res.data);
})
.finally(() => {
loading.value = false;
});
};
// 暴露变量
defineExpose({
openDialog,
});
</script>

View File

@@ -0,0 +1,199 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-row v-show="showSearch">
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
<el-form-item label="数据源" prop="dsName">
<el-select v-model="state.queryForm.dsName" placeholder="请选择数据源">
<el-option label="默认数据源" value="master"></el-option>
<el-option v-for="ds in datasourceList" :key="ds" :label="ds" :value="ds"></el-option>
</el-select>
</el-form-item>
<el-form-item label="表名称" prop="tableName">
<el-input placeholder="请输入表名称或注释" v-model="state.queryForm.tableName" />
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="getDataList"> 查询 </el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row>
<div class="mb8" style="width: 100%">
<el-button plain icon="Refresh" :loading="state.loading" type="primary" v-auth="'knowledge_aiDataTable_add'" @click="handleSync">
同步
</el-button>
<el-button plain :disabled="multiple" icon="Delete" type="primary" v-auth="'knowledge_aiDataTable_del'" @click="handleDelete(selectObjs)">
删除
</el-button>
<right-toolbar
v-model:showSearch="showSearch"
:export="'knowledge_aiDataTable_export'"
@exportExcel="exportExcel"
class="ml10 mr20"
style="float: right"
@queryTable="getDataList"
></right-toolbar>
</div>
</el-row>
<el-table
:data="state.dataList"
v-loading="state.loading"
border
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
@selection-change="selectionChangHandle"
@sort-change="sortChangeHandle"
>
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="dsName" label="数据源" show-overflow-tooltip />
<el-table-column prop="tableName" label="表名称" show-overflow-tooltip />
<el-table-column prop="tableComment" label="物理注释" show-overflow-tooltip />
<el-table-column prop="virtualComment" label="逻辑注释" show-overflow-tooltip />
<el-table-column prop="createTime" label="创建时间" show-overflow-tooltip />
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button
icon="menu"
text
type="primary"
v-auth="'knowledge_aiDataTable_edit'"
@click="fieldDialogRef.openDialog(scope.row.dsName, [scope.row.tableName], true)"
>字段</el-button
>
<el-button icon="edit-pen" text type="primary" v-auth="'knowledge_aiDataTable_edit'" @click="formDialogRef.openDialog(scope.row.tableId)"
>编辑</el-button
>
<el-button icon="delete" text type="primary" v-auth="'knowledge_aiDataTable_del'" @click="handleDelete([scope.row.tableId])"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</div>
<!-- 编辑新增 -->
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
<!-- 字段列表 -->
<field-dialog ref="fieldDialogRef" @refresh="getDataList(false)" />
</div>
</template>
<script setup lang="ts" name="systemAiDataTable">
import { BasicTableProps, useTable } from '/@/hooks/table';
import { fetchList, delObjs, syncObj } from '/@/api/knowledge/aiDataTable';
import { useMessage, useMessageBox } from '/@/hooks/message';
import { list } from '/@/api/gen/datasource';
import { onMounted } from 'vue';
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
const FieldDialog = defineAsyncComponent(() => import('./field.vue'));
// 定义变量内容
const formDialogRef = ref();
const fieldDialogRef = ref();
// 搜索变量
const queryRef = ref();
const showSearch = ref(true);
// 多选变量
const selectObjs = ref([]) as any;
const multiple = ref(true);
// 数据源列表
const datasourceList = ref([]);
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {
tableName: '',
dsName: '',
},
pageList: fetchList,
});
// 加载数据源列表
const loadDatasourceList = async () => {
try {
const { data } = await list();
datasourceList.value = data?.map((item: any) => item.name) || [];
} catch (err) {
// Error handling
}
};
// 页面加载时获取数据源列表
onMounted(() => {
loadDatasourceList();
});
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state);
// 导出excel
const exportExcel = () => {
downBlobFile('/knowledge/aiDataTable/export', Object.assign(state.queryForm, { ids: selectObjs }), 'aiDataTable.xlsx');
};
// 多选事件
const selectionChangHandle = (objs: { tableId: string }[]) => {
selectObjs.value = objs.map(({ tableId }) => tableId);
multiple.value = !objs.length;
};
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
queryRef.value?.resetFields();
// 清空多选
selectObjs.value = [];
// 确保数据源也被重置
state.queryForm.dsName = '';
state.queryForm.tableName = '';
getDataList();
};
// 删除操作
const handleDelete = async (ids: string[]) => {
try {
await useMessageBox().confirm('此操作将永久删除');
} catch {
return;
}
try {
await delObjs(ids);
getDataList();
useMessage().success('删除成功');
} catch (err: any) {
useMessage().error(err.msg);
}
};
// 同步操作
const handleSync = async () => {
try {
state.loading = true;
await syncObj();
getDataList();
useMessage().success('同步成功');
} catch (err: any) {
useMessage().error(err.msg);
} finally {
state.loading = false;
}
};
// 关闭字段抽屉并刷新表格
const closeFieldDrawerAndRefresh = () => {
if (fieldDialogRef.value) {
fieldDialogRef.value.closeDrawer();
}
};
// 暴露方法给父组件
defineExpose({
closeFieldDrawerAndRefresh,
});
</script>

View File

@@ -0,0 +1,549 @@
<template>
<el-drawer v-model="visible" :title="form.id ? '编辑' : '新增'" size="50%">
<el-form ref="dataFormRef" :model="form" :rules="dataRules" label-width="auto" v-loading="loading">
<!-- Tab buttons -->
<div role="tablist" class="mb-5 tabs tabs-boxed abs-bordered">
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'basic' }" @click="activeTab = 'basic'"
><span class="inline-flex justify-center items-center mr-2 w-5 h-5 text-white rounded-full bg-primary">1</span>基础配置</a
>
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'advanced' }" @click="activeTab = 'advanced'"
><span class="inline-flex justify-center items-center mr-2 w-5 h-5 text-white rounded-full bg-primary">2</span>高级配置</a
>
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'security' }" @click="activeTab = 'security'"
><span class="inline-flex justify-center items-center mr-2 w-5 h-5 text-white rounded-full bg-primary">3</span>安全配置</a
>
</div>
<!-- Basic Configuration Tab -->
<div v-show="activeTab === 'basic'" class="px-2">
<el-row :gutter="30">
<el-col :span="12" class="mb-6">
<el-form-item label="头像" prop="avatarUrl">
<upload-img v-model:image-url="form.avatarUrl" />
</el-form-item>
</el-col>
<el-col :span="12" class="mb-6">
<!-- Placeholder for alignment -->
</el-col>
</el-row>
<el-row :gutter="30">
<el-col :span="12" class="mb-6">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" maxlength="20" placeholder="请输入名称" />
</el-form-item>
</el-col>
<el-col :span="12" class="mb-6">
<el-form-item prop="sortOrder">
<template #label>
排序值
<tip content="越大展示越靠前" />
</template>
<el-input-number v-model="form.sortOrder" :min="1" :max="9999" :step="1" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="30">
<el-col :span="24" class="mb-6">
<el-form-item label="欢迎语" prop="welcomeMsg">
<el-input
v-model="form.welcomeMsg"
type="textarea"
placeholder="描述知识库的内容详尽的描述将帮助AI能深入理解该知识库的内容能更准确的检索到内容提高该知识库的命中率。"
maxlength="1024"
show-word-limit
rows="10"
/>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- Advanced Configuration Tab -->
<div v-show="activeTab === 'advanced'" class="px-2">
<el-divider content-position="left">模型配置</el-divider>
<el-row :gutter="30" class="mb-6">
<el-col :span="12">
<el-form-item label="向量库" prop="storeId">
<el-select v-model="form.storeId" placeholder="请选择向量库" clearable filterable :disabled="form.id !== ''">
<el-option v-for="item in storeList" :key="item.storeId" :label="item.name" :value="item.storeId" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="向量模型" prop="embeddingModel">
<el-select v-model="form.embeddingModel" placeholder="请选择向量模型" clearable filterable :disabled="form.id !== ''">
<el-option v-for="item in embeddingModelList" :key="item.id" :label="item.name" :value="item.name" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="30" class="mb-6">
<el-col :span="12">
<el-form-item label="总结模型" prop="summaryModel">
<el-select v-model="form.summaryModel" placeholder="请选择总结模型" clearable filterable>
<el-option v-for="item in chatModelList" :key="item.id" :label="item.name" :value="item.name" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="重排模型" prop="rerankerModel">
<template #label>
重排模型
<tip content="重排模型,如没有重排模型,则后台使用默认重排算法" />
</template>
<el-select v-model="form.rerankerModel" placeholder="请选择重排模型" clearable filterable>
<el-option v-for="item in rerankerModelList" :key="item.id" :label="item.name" :value="item.name" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">检索配置</el-divider>
<el-row :gutter="30" class="mb-6">
<el-col :span="12">
<el-form-item label="会话轮数" prop="multiRound">
<template #label>
会话轮数
<tip content="会话轮数0代表不记忆上文会话" />
</template>
<el-input-number v-model="form.multiRound" :min="0" :max="5" :step="1" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="topK">
<template #label>
匹配条数
<tip content="向量数据库匹配最多几条结果" />
</template>
<el-input-number v-model="form.topK" :min="1" :max="5" :step="1" class="w-full" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="30" class="mb-6">
<el-col :span="12">
<el-form-item prop="fragmentSize">
<template #label>
分片值
<tip content="分片值取决于模型自身能力,理论上分片值越大越准确" />
</template>
<el-input-number v-model="form.fragmentSize" :min="500" :max="9999" :step="1" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="score">
<template #label>
匹配率
<tip content="向量数据库匹配率,建议不低于 50%" />
</template>
<el-slider v-model="form.score" :step="10" :min="10" :max="90" :format-tooltip="(value: number) => value + '%'" show-stops />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="30" class="mb-6">
<el-col :span="12">
<el-form-item prop="emptyLlmFlag">
<template #label>
空查询
<tip content="查询不到结果时,是否调用大模型查询" />
</template>
<el-switch v-model="form.emptyLlmFlag" :active-value="'1'" :inactive-value="'0'" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.emptyLlmFlag !== '1'">
<el-form-item prop="emptyDesc">
<template #label>
空提示
<tip content="未匹配的时候,返回的文案" />
</template>
<el-input v-model="form.emptyDesc" placeholder="请输入描述" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">处理选项</el-divider>
<el-row :gutter="30" class="mb-6">
<el-col :span="12">
<el-form-item label="文档总结" prop="preSummary">
<el-switch v-model="form.preSummary" :active-value="'1'" :inactive-value="'0'" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="aiOcrFlag">
<template #label>
AI OCR
<tip content="PDF、图片等文件自动进行 AI OCR 识别" />
</template>
<el-switch v-model="form.aiOcrFlag" :active-value="'1'" :inactive-value="'0'" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="30" class="mb-6">
<el-col :span="12">
<el-form-item label="会话压缩" prop="preCompress">
<el-switch v-model="form.preCompress" :active-value="'1'" :inactive-value="'0'" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="standardFlag">
<template #label>
标注数据
<tip content="使用已经标注修正后的答案,直接返回" />
</template>
<el-switch v-model="form.standardFlag" :active-value="'1'" :inactive-value="'0'" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- Security Configuration Tab -->
<div v-show="activeTab === 'security'" class="px-2">
<el-divider content-position="left">访问控制</el-divider>
<el-row :gutter="30" class="mb-6">
<el-col :span="12">
<el-form-item label="是否对外" prop="publicFlag">
<el-switch v-model="form.publicFlag" :active-value="'1'" :inactive-value="'0'" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="publicPassword">
<template #label>
安全密钥
<tip content="对外服务,需要用户输入的密码" />
</template>
<el-input v-model="form.publicPassword" placeholder="请输入密码" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="30" class="mb-6">
<el-col :span="24">
<el-form-item prop="visibleUsers">
<template #label>
可见范围
<tip content="选择可以访问此知识库的用户,如果不选择则全部的用户可访问" />
</template>
<org-selector
v-model="form.visibleUsers"
:type="'user'"
:multiple="true"
:selectSelf="true"
/>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">内容过滤</el-divider>
<el-row :gutter="30" class="mb-6">
<el-col :span="12">
<el-form-item label="敏感词过滤" prop="sensitiveFlag">
<el-switch v-model="form.sensitiveFlag" :active-value="'1'" :inactive-value="'0'" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="sensitiveMsg">
<template #label>
提示
<tip content="命中敏感词,返回的文案" />
</template>
<el-input v-model="form.sensitiveMsg" placeholder="请输入描述" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">附加信息</el-divider>
<el-row :gutter="30" class="mb-6">
<el-col :span="24">
<el-form-item label="底部信息" prop="footer">
<el-input type="textarea" maxlength="255" :rows="5" v-model="form.footer" placeholder="聊天框底部的信息,支持 HTML 语法" />
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading">确认</el-button>
</span>
</template>
</el-drawer>
</template>
<script setup lang="ts" name="AiDatasetDialog">
// import { useDict } from '/@/hooks/dict'; // Removed as it's unused
import { useMessage } from '/@/hooks/message';
import { getDetails, addObj, putObj, validateName } from '/@/api/knowledge/aiDataset';
import { list as aiModelList } from '/@/api/knowledge/aiModel';
import { list } from '/@/api/knowledge/aiEmbedStore';
import { rule } from '/@/utils/validate';
import UploadImg from '/@/components/Upload/Image.vue';
import OrgSelector from '/@/components/OrgSelector/index.vue';
const emit = defineEmits(['refresh']);
// 定义变量内容
const dataFormRef = ref();
const visible = ref(false);
const loading = ref(false);
const activeTab = ref('basic'); // Added for tab management
// 提交表单数据
const form = reactive({
id: '',
name: '',
avatarUrl: '',
description: '',
units: '0',
fileSize: '0',
multiRound: 3,
topK: 2,
score: 40,
fragmentSize: 1000,
sortOrder: 1,
emptyLlmFlag: '0',
emptyDesc: '知识库未匹配相关问题,请重新提问',
sensitiveFlag: '1',
preSummary: '0',
preCompress: '0',
sensitiveMsg: '您输入内容包含敏感词,请重新输入',
publicFlag: '1',
publicPassword: '' as string | undefined, // Allow undefined for publicPassword
standardFlag: '0',
aiOcrFlag: '1',
welcomeMsg: '',
footer: '',
embeddingModel: '',
summaryModel: '',
storeId: '',
rerankerModel: '',
visibleUsers: [] as Array<any>, // 可见范围用户列表
});
// 定义校验规则
const dataRules = ref({
name: [
{ required: true, message: '知识库名称不能为空', trigger: 'blur' },
{
validator: (rule: any, value: any, callback: any) => {
validateName(rule, value, callback, form.id !== '');
},
trigger: 'blur',
},
],
welcomeMsg: [{ required: true, message: '欢迎语不能为空', trigger: 'blur' }],
multiRound: [{ required: true, message: '多轮会话不能为空', trigger: 'blur' }],
topK: [{ required: true, message: '多轮会话不能为空', trigger: 'blur' }],
avatarUrl: [{ required: true, message: '封面不能为空', trigger: 'blur' }],
emptyDesc: [
{ validator: rule.overLength, trigger: 'blur' },
{
required: true,
message: '提示不能为空',
trigger: 'blur',
},
],
sensitiveMsg: [
{ validator: rule.overLength, trigger: 'blur' },
{
required: true,
message: '提示不能为空',
trigger: 'blur',
},
],
storeId: [{ required: true, message: '请选择向量库', trigger: 'change' }],
embeddingModel: [{ required: true, message: '请选择向量模型', trigger: 'change' }],
summaryModel: [{ required: true, message: '请选择总结模型', trigger: 'change' }],
rerankerModel: [
{
validator: rule.overLength,
trigger: 'blur',
},
],
});
const embeddingModelList = ref<Array<{ id: string; name: string }>>([]);
const chatModelList = ref<Array<{ id: string; name: string }>>([]);
const storeList = ref<Array<{ storeId: string; name: string }>>([]);
const rerankerModelList = ref<Array<{ id: string; name: string }>>([]);
// Modify the function to fetch AI models based on modelType
async function loadAiModelList() {
try {
const [embeddingResponse, chatResponse, rerankerResponse] = await Promise.all([
aiModelList({ modelType: 'Embedding' }),
aiModelList({ modelType: ['Chat', 'Reason'] }),
aiModelList({ modelType: 'Reranker' }),
]);
embeddingModelList.value = embeddingResponse.data.map((item: any) => ({
id: item.id,
name: item.name,
}));
chatModelList.value = chatResponse.data.map((item: any) => ({
id: item.id,
name: item.name,
}));
rerankerModelList.value = rerankerResponse.data.map((item: any) => ({
id: item.id,
name: item.name,
}));
// Set default values if lists are not empty
if (embeddingModelList.value.length > 0 && !form.embeddingModel) {
form.embeddingModel = embeddingModelList.value[0].name;
}
if (chatModelList.value.length > 0 && !form.summaryModel) {
form.summaryModel = chatModelList.value[0].name;
}
if (rerankerModelList.value.length > 0 && !form.rerankerModel) {
form.rerankerModel = rerankerModelList.value[0].name;
}
} catch (error) {
useMessage().error('加载AI模型列表失败');
}
}
// 初始化表单数据
const getAiDatasetData = (id: string) => {
// 获取数据
loading.value = true;
getDetails({ id })
.then((res: any) => {
Object.assign(form, res.data);
})
.finally(() => {
loading.value = false;
});
};
// Modify the openDialog function
const openDialog = async (id: string) => {
visible.value = true;
form.id = '';
// Reset form data
nextTick(() => {
dataFormRef.value?.resetFields();
});
// Load the collection list and AI model list
await loadAiModelList();
// 初始化向量库列表
const { data } = await list();
storeList.value = data;
// Set default value for storeId if list is not empty
if (storeList.value.length > 0 && !form.storeId) {
form.storeId = storeList.value[0].storeId;
}
// Get aiDataset information
if (id) {
form.id = id;
getAiDatasetData(id);
} else {
// Set default values for new records
if (embeddingModelList.value.length > 0) {
form.embeddingModel = embeddingModelList.value[0].name;
}
if (chatModelList.value.length > 0) {
form.summaryModel = chatModelList.value[0].name;
}
if (rerankerModelList.value.length > 0) {
form.rerankerModel = rerankerModelList.value[0].name;
}
}
};
// 提交
const onSubmit = async () => {
try {
// 步骤 1: 异步验证整个表单
// dataFormRef.value.validate() 会返回一个 Promise
// 如果验证失败,它会抛出错误,该错误将被下面的 catch 块捕获
await dataFormRef.value.validate();
// 步骤 2: 如果验证通过,则继续执行提交逻辑
// 特殊处理:如果存在 ID (即为编辑模式) 且密码字段包含 '**' (表示未修改),
// 则将密码设置为空,避免将占位符提交到后端
if (form.id && form.publicPassword?.includes('**')) {
form.publicPassword = undefined;
}
loading.value = true; // 开始加载状态,禁用提交按钮等
// 根据是否存在 form.id 来判断是更新 (putObj) 还是新增 (addObj)
form.id ? await putObj(form) : await addObj(form);
// 操作成功提示
useMessage().success(form.id ? '修改成功' : '添加成功');
visible.value = false; // 关闭抽屉(或弹窗)
emit('refresh'); // 触发 'refresh' 事件,通知父组件刷新列表数据
} catch (invalidFields: any) {
// 步骤 3: 处理表单验证失败的情况
// invalidFields 对象包含了所有未通过验证的字段及其错误信息
// Element Plus 的 validate 方法在验证失败时会 reject 一个包含错误字段的对象
if (invalidFields && Object.keys(invalidFields).length > 0) {
const errorFields: string[] = Object.keys(invalidFields); // 获取所有验证失败的字段名
// 定义各选项卡包含的字段,用于定位错误字段所在的选项卡
const tabFields: Record<string, string[]> = {
basic: ['name', 'avatarUrl', 'welcomeMsg', 'sortOrder'], // 基础配置选项卡下的字段
advanced: [
'storeId',
'embeddingModel',
'summaryModel',
'multiRound',
'topK',
'fragmentSize',
'score',
'emptyLlmFlag',
'emptyDesc',
'preSummary',
'preCompress',
'aiOcrFlag',
'standardFlag',
'rerankerModel',
], // 高级配置选项卡下的字段
security: ['publicFlag', 'publicPassword', 'visibleUsers', 'sensitiveFlag', 'sensitiveMsg', 'footer'], // 安全配置选项卡下的字段
};
// 遍历 tabFields 来确定哪个选项卡包含第一个出错的字段
for (const [tab, fieldsInTab] of Object.entries(tabFields)) {
// .some() 方法检查 errorFields 数组中是否有任何一个字段存在于当前 fieldsInTab 数组中
if (errorFields.some((errorField: string) => fieldsInTab.includes(errorField))) {
activeTab.value = tab; // 如果找到,则切换到该选项卡
break; // 找到第一个错误字段所在的选项卡后,停止遍历
}
}
}
// 如果验证失败,错误被捕获,提交过程在此处停止。
// 函数将继续执行 finally 块。
} finally {
// 步骤 4: 无论提交成功还是失败,最后都会执行 finally 块
loading.value = false; // 结束加载状态
}
};
// 暴露变量
defineExpose({
openDialog,
});
</script>

View File

@@ -0,0 +1,243 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="formDialogRef.openDialog()" v-auth="'knowledge_aiDataset_add'">
</el-button>
<el-button plain :disabled="multiple" icon="Delete" type="primary" v-auth="'knowledge_aiDataset_del'" @click="handleDelete(selectObjs)">
删除
</el-button>
<right-toolbar
v-model:showSearch="showSearch"
:export="'knowledge_aiDataset_export'"
@exportExcel="exportExcel"
class="ml10 mr20"
style="float: right"
@queryTable="getDataList"
></right-toolbar>
</div>
</el-row>
<el-scrollbar class="h-[calc(100vh-280px)] mb-4">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div
v-for="dataset in state.dataList"
:key="dataset.id"
class="group overflow-hidden bg-white rounded-lg shadow-sm border border-gray-100 transition-all duration-300 cursor-pointer dark:bg-gray-800 dark:border-gray-700 hover:shadow-lg hover:border-primary-100 hover:translate-y-[-2px]"
@dblclick="go2document(dataset)"
>
<div class="p-5">
<div class="flex items-start">
<div class="flex overflow-hidden justify-center items-center rounded-lg border border-gray-200 size-12 dark:border-gray-700">
<img :src="baseURL + dataset.avatarUrl" alt="Avatar" class="object-cover w-full h-full" />
</div>
<div class="overflow-hidden flex-1 ml-3">
<div class="text-base font-medium text-gray-900 truncate dark:text-white">
{{ dataset.name }}
</div>
<div class="flex items-center mt-1 text-xs text-gray-500 dark:text-gray-400">
<el-icon class="mr-1"><Document /></el-icon>
文档数{{ dataset.units || 0 }}
</div>
</div>
<div v-if="dataset.publicFlag === '1'" class="ml-2">
<el-tag size="small" type="success">已发布</el-tag>
</div>
</div>
<div class="overflow-y-auto mt-4 h-16 text-sm text-gray-600 dark:text-gray-300 line-clamp-3">
{{ dataset.description || '暂无描述' }}
</div>
<div class="flex justify-start items-center pt-3 mt-4 border-t border-gray-100 dark:border-gray-700">
<el-button
v-if="dataset.publicFlag === '1'"
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
v-auth="'knowledge_aiDataset_edit'"
@click="mountDialogRef.openDialog(dataset.id)"
>
<el-icon><Link /></el-icon>
</el-button>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
v-auth="'knowledge_aiDataset_edit'"
@click="formDialogRef.openDialog(dataset.id)"
>
<el-icon><EditPen /></el-icon>
</el-button>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
v-auth="'knowledge_aiDataset_del'"
@click="handleDelete([dataset.id])"
>
<el-icon><Delete /></el-icon>
</el-button>
<div class="mx-2 w-px h-4 bg-gray-200 dark:bg-gray-700"></div>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
@click="recallDialogRef.openDialog(dataset)"
>
<el-icon><Connection /></el-icon>
</el-button>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
@click="handleNavigateToChat(dataset)"
>
<el-icon><ChatDotRound /></el-icon>
</el-button>
<div class="flex-grow"></div>
<el-checkbox
class="ml-4"
:value="selectObjs.includes(dataset.id)"
@change="(val: boolean) => handleCardSelect(val, dataset.id)"
></el-checkbox>
</div>
</div>
</div>
</div>
</el-scrollbar>
<!-- 无数据显示 -->
<el-empty v-if="!state.dataList || state.dataList.length === 0" description="暂无数据"></el-empty>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</div>
<!-- 编辑新增 -->
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
<mount-dialog ref="mountDialogRef" />
<recall-dialog ref="recallDialogRef" />
</div>
</template>
<script setup lang="ts" name="systemAiDataset">
import { BasicTableProps, useTable } from '/@/hooks/table';
import { fetchList, delObjs } from '/@/api/knowledge/aiDataset';
import { useMessage, useMessageBox } from '/@/hooks/message';
import { EditPen, Delete, Document, Link, ChatDotRound, Connection } from '@element-plus/icons-vue';
import { computed } from 'vue';
const router = useRouter();
const baseURL = computed(() => import.meta.env.VITE_API_URL);
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
const MountDialog = defineAsyncComponent(() => import('./mount.vue'));
const RecallDialog = defineAsyncComponent(() => import('./recall.vue'));
// 定义变量内容
const formDialogRef = ref();
const mountDialogRef = ref();
const recallDialogRef = ref();
// 搜索变量
const queryRef = ref();
const showSearch = ref(true);
// 多选变量
const selectObjs = ref([]) as any;
const multiple = ref(true);
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {},
pageList: fetchList,
dataList: [],
});
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, downBlobFile } = useTable(state);
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
queryRef.value?.resetFields();
// 清空多选
selectObjs.value = [];
getDataList();
};
// 导出excel
const exportExcel = () => {
downBlobFile('/knowledge/aiDataset/export', Object.assign(state.queryForm, { ids: selectObjs }), 'aiDataset.xlsx');
};
// 多选事件 - 为卡片视图添加的选择函数
const handleCardSelect = (selected: boolean, dataId: string) => {
if (selected) {
selectObjs.value.push(dataId);
} else {
selectObjs.value = selectObjs.value.filter((id: string) => id !== dataId);
}
multiple.value = selectObjs.value.length === 0;
};
// 删除操作
const handleDelete = async (ids: string[]) => {
try {
await useMessageBox().confirm('此操作将永久删除');
} catch {
return;
}
try {
await delObjs(ids);
getDataList();
useMessage().success('删除成功');
} catch (err: any) {
useMessage().error(err.msg);
}
};
const go2document = (dataset: any) => {
router.push({
path: '/knowledge/aiDocument/index',
query: {
datasetId: dataset.id,
},
});
};
const handleNavigateToChat = (dataset: any) => {
router.push({
path: '/knowledge/aiChat/index',
query: {
datasetId: dataset.id,
},
});
};
</script>
<style lang="scss" scoped>
:deep(.el-scrollbar__wrap) {
overflow-x: hidden !important;
}
:deep(.el-checkbox) {
margin-right: 0;
}
.bg-primary-100 {
background-color: var(--el-color-primary-light-9);
}
.text-primary {
color: var(--el-color-primary);
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<el-dialog
v-model="dialogVisible"
title="选择挂载方式"
width="50%"
:modal="false"
>
<div>
<el-card size="mini">
<el-row :gutter="10" justify="center">
<el-col :span="8">
<div :class="selectedMethod === 1 ? 'img-selected' : 'img-unselect'" @click="selectImg(1)">
<div class="css-1awpln7">
<div class="css-0">
<img alt="" src="/@/assets/ai/link.svg" class="chakra-image css-0">
</div>
</div>
</div>
</el-col>
<el-col :span="8">
<div :class="selectedMethod === 2 ? 'img-selected' : 'img-unselect'" @click="selectImg(2)">
<div class="css-1awpln7">
<div class="css-0">
<img alt="" src="/@/assets/ai/iframe.svg" class="chakra-image css-0">
</div>
</div>
</div>
</el-col>
<el-col :span="8">
<div :class="selectedMethod === 3 ? 'img-selected' : 'img-unselect'" @click="selectImg(3)">
<div class="css-1awpln7">
<div class="css-0">
<img alt="" src="/@/assets/ai/script.svg" class="chakra-image css-0">
</div>
</div>
</div>
</el-col>
</el-row>
</el-card>
</div>
<div>
<el-card size="mini" body-class="card-class">
<template #header>
<div class="card-header">
<div v-if="selectedMethod === 1">
<span>将下面链接复制到浏览器打开</span>
<span>
<el-button style="float: right" class="ml-2" type="primary" @click="openText(url())">打开</el-button>
<el-button style="float: right" type="primary" @click="copyText(url())">复制</el-button>
</span>
</div>
<div v-if="selectedMethod === 2">
<span>复制下面 Iframe 加入到你的网站中</span>
<span>
<el-button style="float: right" type="primary" @click="copyText(iframe())">复制</el-button>
</span>
</div>
<div v-if="selectedMethod === 3">
<span>将下面代码加入到你的网站中</span>
<span>
<el-button style="float: right" type="primary" @click="copyText(script())">复制</el-button>
</span>
</div>
</div>
</template>
<span v-if="selectedMethod === 1">
<div class="mockup-code">
<pre data-prefix="$"><code> {{ url() }}</code></pre>
</div>
</span>
<span v-if="selectedMethod === 2">
<div class="mockup-code">
<pre data-prefix="$"><code> {{ iframe() }}</code></pre>
</div>
</span>
<span v-if="selectedMethod === 3">
<div class="mockup-code">
<pre data-prefix="$"><code> {{ script() }}</code></pre>
</div>
</span>
</el-card>
</div>
</el-dialog>
</template>
<script setup lang="ts" name="AiDatasetDialog">
import commonFunction from "/@/utils/commonFunction";
const emit = defineEmits(['refresh']);
const {copyText} = commonFunction();
const left_right = [
{
label: '左侧',
value: 'left',
},
{
label: '右侧',
value: 'right',
}
]
const top_bottom = [
{
label: '顶部',
value: 'top',
},
{
label: '底部',
value: 'bottom',
}
]
const dialogVisible = ref(false)
const selectedDatasetId = ref()
const selectedMethod = ref(1)
const data_btn_x = ref(16)
const data_btn_y = ref(16)
const data_stream = ref(true)
const data_direction_x = ref('right')
const data_direction_y = ref('bottom')
const openDialog = (datasetid: any) => {
dialogVisible.value = true
selectedDatasetId.value = datasetid
}
const currentHostname = window.location.origin;
const openImageBase64 = ref("")
const closeImageBase64 = ref("")
function handleOpenIconBeforeUpload(file: Blob) {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = e => {
// 在这里处理 Base64 数据
openImageBase64.value = e.target.result
};
return false;
}
function handleCloseIconBeforeUpload(file: Blob) {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = e => {
// 在这里处理 Base64 数据
closeImageBase64.value = e.target.result
};
return false;
}
const isMicro = import.meta.env.VITE_IS_MICRO === 'true' ? 1 : 0
const script = () => {
return `<script id="chatbot-iframe" src="${currentHostname}/bot/embed.min.js?t=${Date.now()}" data-bot-src="${currentHostname}/bot/index.html#/${isMicro}/${selectedDatasetId.value}/chat" async defer><\/script>`
}
const iframe = () => {
return `<iframe src="${currentHostname}/bot/bot/index.html#/${isMicro}/${selectedDatasetId.value}/chat" style="width: 100%; height: 100%;" frameborder="0" allow="microphone"/>`
}
const url = () => {
return `${currentHostname}/bot/index.html#/${isMicro}/${selectedDatasetId.value}/chat`
}
const selectImg = (index: number) => {
selectedMethod.value = index
}
const openText = (url: string) => {
window.open(url)
}
// 暴露变量
defineExpose({
openDialog
});
</script>
<style scoped>
.img-unselect {
display: flex;
-webkit-box-align: center;
align-items: center;
cursor: pointer;
user-select: none;
border: 1.5px solid rgb(232, 235, 240);
border-radius: 5px;
position: relative;
background: #fbfbfc;
padding: 0px !important;
}
.img-unselect:hover {
border-color: #1dbcd8;
}
.img-selected {
display: flex;
-webkit-box-align: center;
align-items: center;
cursor: pointer;
user-select: none;
border-style: solid;
border-image: initial;
border-width: 1.5px;
border-radius: 5px;
position: relative;
border-color: #1dbcd8;
background: #f0f4ff;
padding: 0px !important;
}
.card-class {
background-color: #F4F4F7;
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<el-drawer v-model="visible" title="召回测试" size="50%">
<el-form ref="dataFormRef" :model="form" :rules="dataRules" label-width="auto" v-loading="loading">
<el-row>
<el-col :span="12" class="mb20">
<el-form-item label="知识库名称">
<el-input :placeholder="dataset?.name" disabled />
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="召回数量" prop="topK">
<el-input-number v-model="form.topK" :min="1" :max="10" :step="1" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12" class="mb20">
<el-form-item label="是否全文检索" prop="fullTextSearch">
<el-radio-group v-model="form.fullTextSearch">
<el-radio v-for="(item, index) in yes_no_type" :key="index" :label="item.value" border>{{ item.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="是否重排" prop="reRanking">
<el-radio-group v-model="form.reRanking">
<el-radio v-for="(item, index) in yes_no_type" :key="index" :label="item.value" border>{{ item.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24" class="mb20">
<el-form-item label="问题" prop="content">
<el-input v-model="form.content" type="textarea" placeholder="请输入需要召回的问题内容" maxlength="1024" show-word-limit rows="6" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="center">召回结果</el-divider>
<el-table v-if="tableData.length > 0" :data="tableData" border style="width: 100%">
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="score" label="相似度" width="120" />
<el-table-column prop="content" label="切片" :show-overflow-tooltip="true" />
<el-table-column prop="metadata" label="元数据" width="200" :show-overflow-tooltip="true" />
</el-table>
<el-empty v-else description="暂无数据" />
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleTest" :disabled="loading || !form.content">测试</el-button>
</span>
</template>
</el-drawer>
</template>
<script setup lang="ts" name="AiDatasetRecall">
import { useMessage } from '/@/hooks/message';
import { fetchRecall } from '/@/api/knowledge/aiSlice';
import { useDict } from '/@/hooks/dict';
// 定义接口
interface RecallParams {
datasetId: string;
content: string;
topK: number;
fullTextSearch: boolean;
reRanking: boolean;
}
// 定义变量内容
const visible = ref(false);
const loading = ref(false);
const tableData = ref([]);
const dataset = ref<any>(null);
// 定义字典
const { yes_no_type } = useDict('yes_no_type');
// 定义表单变量
interface FormState {
datasetId: string;
content: string;
topK: number;
fullTextSearch: string;
reRanking: string;
}
const form = reactive<FormState>({
datasetId: '',
content: '',
topK: 5,
fullTextSearch: '1', // 是否全文检索
reRanking: '1', // 是否重排
});
// 表单校验规则
const dataRules = ref({
content: [{ required: true, message: '请输入问题内容', trigger: 'blur' }],
});
// 打开抽屉
const openDialog = (datasetVal: any) => {
visible.value = true;
form.datasetId = datasetVal.id;
dataset.value = datasetVal;
resetForm();
};
// 重置表单
const resetForm = () => {
form.content = '';
tableData.value = [];
};
// 测试召回
const handleTest = async () => {
if (!form.content) {
useMessage().warning('请输入问题内容');
return;
}
loading.value = true;
try {
const { data } = await fetchRecall({
datasetId: form.datasetId,
content: form.content,
topK: form.topK,
fullTextSearch: form.fullTextSearch === '1',
reRanking: form.reRanking === '1',
} as RecallParams);
if (data) {
tableData.value = data;
} else {
tableData.value = [];
useMessage().warning('未查询到数据');
}
} catch (error: any) {
useMessage().error(error.msg || '操作失败');
tableData.value = [];
} finally {
loading.value = false;
}
};
// 暴露方法
defineExpose({
openDialog,
});
</script>
<style lang="scss" scoped>
:deep(.el-drawer__body) {
padding: 20px;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,324 @@
<template>
<el-dialog :title="form.id ? '编辑' : '新增'" v-model="visible" :width="800" :close-on-click-modal="false" draggable :destroy-on-close="true" class="dark:bg-gray-800">
<el-form
ref="dataFormRef"
:model="form"
:rules="dataRules"
formDialogRef
@submit.prevent
label-width="90px"
v-loading="loading"
class="dark:text-gray-300"
>
<el-collapse v-model="activeNames" class="dark:border-gray-700">
<el-row :gutter="24">
<el-col :span="12" class="mt-8 mb20">
<el-form-item label="知识库" prop="datasetId" class="dark:text-gray-300">
<el-select v-model="form.datasetId" placeholder="请选择知识库" class="dark:bg-gray-700">
<el-option v-for="item in datasetList" :key="item.id" :label="item.name" :value="item.id" class="dark:hover:bg-gray-600" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12" class="mt-8 mb20">
<el-form-item label="来源" prop="sourceType" class="dark:text-gray-300">
<el-radio-group v-model="form.sourceType" class="dark:text-gray-300">
<el-radio-button
v-for="item in source_type"
:key="item.value"
:label="item.value"
border
class="dark:border-gray-600 dark:text-gray-300"
>
{{ item.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<TextDocumentForm v-if="form.sourceType === '2'" v-model="form" />
<FileDocumentForm v-else-if="form.sourceType === '1'" v-model="form.files" />
<QADocumentForm v-else-if="form.sourceType === '3'" v-model="form.files" />
<CrawlerDocumentForm v-else-if="form.sourceType === '4'" v-model="form" />
<el-collapse-item name="3" class="dark:border-gray-700" :disabled="false">
<template #title>
<div class="flex items-center mb-3 font-medium text-gray-700 dark:text-gray-300">
<el-icon class="mr-1"><Setting /></el-icon>
分片设置
</div>
</template>
<el-row :gutter="24" class="mb-4">
<el-col :span="24">
<el-form-item label="分片算法" prop="sliceType" class="dark:text-gray-300">
<div class="flex flex-wrap gap-2 items-center">
<div
v-for="item in slice_algorithm_types"
:key="item.value"
:class="[
'cursor-pointer mr-6 border rounded-lg py-2 px-3 flex items-center transition-all hover:shadow-sm',
form.sliceType === item.value ? 'border-blue-500 shadow-sm dark:bg-gray-700' : 'border-gray-200 dark:border-gray-600',
]"
@click="form.sliceType = item.value"
>
<div
:class="[
'rounded-full w-6 h-6 flex items-center justify-center mr-2',
form.sliceType === item.value ? 'bg-blue-500' : 'bg-gray-100 dark:bg-gray-600',
]"
>
<el-icon :class="['text-lg', form.sliceType === item.value ? 'text-white' : 'text-gray-500 dark:text-gray-300']">
<component :is="getIconForType(item.value)" />
</el-icon>
</div>
<span :class="['text-sm', form.sliceType === item.value ? 'font-medium text-blue-500 dark:text-blue-400' : 'dark:text-gray-300']">
{{ item.label }}
</span>
</div>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24" class="mt-6">
<el-col :span="12">
<el-form-item label="分片值" prop="maxSegmentSizeInTokens" class="dark:text-gray-300">
<template #label>分片值 <tip content="每个分片里面字符总数量" /> </template>
<el-input-number
v-model="form.maxSegmentSizeInTokens"
:min="500"
:max="4000"
:step="100"
class="w-full dark:bg-gray-700"
placeholder="请输入分片大小"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="maxOverlapSizeInTokens" class="dark:text-gray-300">
<template #label>重叠值 <tip content="是指分片之间的重叠大小,避免分割丢失上下文" /> </template>
<el-input-number
v-model="form.maxOverlapSizeInTokens"
:min="0"
:max="200"
:step="50"
class="w-full dark:bg-gray-700"
placeholder="请输入重叠大小"
/>
</el-form-item>
</el-col>
</el-row>
</el-collapse-item>
</el-collapse>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false" class="dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"> 取消 </el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading" class="dark:border-gray-600"> 确认 </el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="AiDocumentDialog">
import { defineAsyncComponent } from 'vue';
import { useDict } from '/@/hooks/dict';
import { useMessage } from '/@/hooks/message';
import { getObj, addObj, putObj } from '/@/api/knowledge/aiDocument';
import { fetchDataList } from '/@/api/knowledge/aiDataset';
import { rule } from '/@/utils/validate';
import { Setting, Document, ChatLineRound, Reading, Collection, Paperclip, Operation, Link } from '@element-plus/icons-vue';
const TextDocumentForm = defineAsyncComponent(() => import('./sources/TextDocumentForm.vue'));
const FileDocumentForm = defineAsyncComponent(() => import('./sources/FileDocumentForm.vue'));
const QADocumentForm = defineAsyncComponent(() => import('./sources/QADocumentForm.vue'));
const CrawlerDocumentForm = defineAsyncComponent(() => import('./sources/CrawlerDocumentForm.vue'));
const emit = defineEmits(['refresh']);
// 定义变量内容
const { source_type } = useDict('source_type');
const dataFormRef = ref();
const route = useRoute();
const visible = ref(false);
const loading = ref(false);
const fileType = ref(['jpeg', 'png', 'jpg', 'gif', 'md', 'doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx', 'html']);
/**
* 文档分片算法枚举
*/
enum SliceAlgorithm {
PARAGRAPH = 'paragraph', // 段落分割器
LINE = 'line', // 行分割器
SENTENCE = 'sentence', // 句子分割器
WORD = 'word', // 单词分割器
CHARACTER = 'character', // 字符分割器
REGEX = 'regex', // 正则表达式分割器
RECURSIVE = 'recursive', // 递归智能分割器
}
// 分片算法选项
const slice_algorithm_types = [
{ value: SliceAlgorithm.RECURSIVE, label: '智能分片' },
{ value: SliceAlgorithm.PARAGRAPH, label: '段落分片' },
{ value: SliceAlgorithm.SENTENCE, label: '句子分片' },
{ value: SliceAlgorithm.CHARACTER, label: '字符分片' },
];
// 提交表单数据
const form = reactive({
id: '',
name: '',
datasetId: '',
fileType: '',
content: '',
files: [],
sourceType: '1',
sliceCount: '',
hitCount: '',
fileSize: '',
fileStatus: '1',
sliceType: SliceAlgorithm.RECURSIVE,
repoType: '',
repoOwner: '',
repoName: '',
accessToken: '',
maxSegmentSizeInTokens: 1000,
maxOverlapSizeInTokens: 50,
});
// 定义校验规则
const dataRules = ref({
datasetId: [{ required: true, message: '所属知识库不能为空', trigger: 'blur' }],
name: [
{ validator: rule.overLength, trigger: 'blur' },
{ required: true, message: '文件名不能为空', trigger: 'blur' },
],
content: [{ required: true, message: '内容不能为空', trigger: 'blur' }],
url: [
{ required: true, message: '网址不能为空', trigger: 'blur' },
{ validator: rule.overLength, trigger: 'blur' },
{ type: 'url', message: '请输入正确的网址', trigger: 'blur' },
],
settings: [{ required: true, message: '请输入配置', trigger: 'change' }],
sliceType: [{ required: true, message: '请选择分片算法', trigger: 'change' }],
maxSegmentSizeInTokens: [{ required: true, message: '请输入分片大小', trigger: 'blur' }],
maxOverlapSizeInTokens: [{ required: true, message: '请输入重叠大小', trigger: 'blur' }],
files: [
{
validator: (rule: any, value: any, callback: any) => {
if (form.sourceType === '1' && (!form.files || form.files.length === 0)) {
callback(new Error('文件不能为空'));
} else {
callback();
}
},
trigger: 'change',
},
],
});
// 打开弹窗
const openDialog = (id: string) => {
visible.value = true;
form.id = '';
form.files = [];
// 重置表单数据
nextTick(() => {
dataFormRef.value?.resetFields();
});
getDatasetList();
// 获取aiDocument信息
if (id) {
form.id = id;
getAiDocumentData(id);
}
};
// 监听 form.sourceType 变化,如果 sourceType === 1 则打开 excelUploadRef.show()
watch(
() => form.sourceType,
(value) => {
if (value === '3') {
fileType.value = ['xlsx'];
} else {
fileType.value = ['jpeg', 'png', 'jpg', 'gif', 'md', 'doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx', 'html'];
}
}
);
// 提交
const onSubmit = async () => {
const valid = await dataFormRef.value.validate().catch(() => {});
if (!valid) return false;
try {
loading.value = true;
form.id ? await putObj(form) : await addObj(form);
useMessage().success(form.id ? '修改成功' : '添加成功');
visible.value = false;
emit('refresh');
} catch (err: any) {
useMessage().error(err.msg);
} finally {
loading.value = false;
}
};
const datasetList = ref<{ id: string; name: string }[]>([]);
const getDatasetList = async () => {
const { data } = await fetchDataList();
datasetList.value = data;
};
// 初始化表单数据
const getAiDocumentData = (id: string) => {
// 获取数据
loading.value = true;
getObj(id)
.then((res: any) => {
Object.assign(form, res.data);
})
.finally(() => {
loading.value = false;
});
};
onMounted(() => {
const datasetId = route.query.datasetId;
if (typeof datasetId === 'string') {
form.datasetId = datasetId;
}
});
// 暴露变量
defineExpose({
openDialog,
});
// 新增的响应式变量
const activeNames = ref(['1', '2', '3']);
// 获取对应切片类型的图标
const getIconForType = (type: string) => {
switch (type) {
case SliceAlgorithm.PARAGRAPH:
return Document;
case SliceAlgorithm.LINE:
return Reading;
case SliceAlgorithm.SENTENCE:
return ChatLineRound;
case SliceAlgorithm.WORD:
return Collection;
case SliceAlgorithm.CHARACTER:
return Paperclip;
case SliceAlgorithm.REGEX:
return Operation;
case SliceAlgorithm.RECURSIVE:
return Link;
default:
return Document;
}
};
</script>

View File

@@ -0,0 +1,281 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-row v-show="showSearch">
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
<el-form-item label="知识库名" prop="title">
<el-select placeholder="请选择知识库" v-model="state.queryForm.datasetId">
<el-option :key="index" :label="item.name" :value="item.id" v-for="(item, index) in datasetList">
{{ item.name }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="切片状态" prop="sliceStatus">
<el-select placeholder="请选择状态" v-model="state.queryForm.sliceStatus">
<el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in slice_status">
{{ item.label }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="总结状态" prop="summaryStatus">
<el-select placeholder="请选择状态" v-model="state.queryForm.summaryStatus">
<el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in summary_status">
{{ item.label }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="文件名" prop="name">
<el-input placeholder="请输入文件名" v-model="state.queryForm.name" />
</el-form-item>
<el-form-item label="文件来源" prop="sourceType">
<el-select placeholder="请选择状态" v-model="state.queryForm.sourceType">
<el-option :key="item.value" :label="item.label" :value="item.value" v-for="item in source_type">
{{ item.label }}
</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="getDataList"> 查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="formDialogRef.openDialog()" v-auth="'knowledge_aiDocument_add'">
</el-button>
<el-button plain :disabled="multiple" icon="Delete" type="primary" v-auth="'knowledge_aiDocument_del'" @click="handleDelete(selectObjs)">
删除
</el-button>
<right-toolbar
v-model:showSearch="showSearch"
:export="'knowledge_aiDocument_export'"
@exportExcel="exportExcel"
class="ml10 mr20"
style="float: right"
@queryTable="getDataList"
></right-toolbar>
</div>
</el-row>
<el-table
:data="state.dataList"
v-loading="state.loading"
border
@row-dblclick="go2slice"
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
@selection-change="selectionChangHandle"
@sort-change="sortChangeHandle"
>
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="name" label="名称" width="200" show-overflow-tooltip />
<el-table-column prop="fileType" label="文件类型" show-overflow-tooltip />
<el-table-column prop="sourceType" label="文件来源" show-overflow-tooltip>
<template #default="scope">
<dict-tag :options="source_type" :value="scope.row.sourceType"></dict-tag>
</template>
</el-table-column>
<el-table-column prop="sliceCount" label="切片数量" show-overflow-tooltip />
<el-table-column prop="hitCount" label="命中次数" show-overflow-tooltip />
<el-table-column prop="fileStatus" width="100" show-overflow-tooltip>
<template #header>
切片结果
<tip content="点击【失败】标签可查看失败原因" />
</template>
<template #default="scope">
<template v-if="scope.row.sliceStatus === '9'">
<el-tooltip placement="top">
<template #content>{{ scope.row.sliceFailReason }}</template>
<dict-tag :options="slice_status" :value="scope.row.sliceStatus" />
</el-tooltip>
</template>
<template v-else>
<dict-tag :options="slice_status" :value="scope.row.sliceStatus" />
</template>
</template>
</el-table-column>
<el-table-column prop="summaryStatus" width="100" show-overflow-tooltip>
<template #header>
总结结果
<tip content="点击【失败】标签可查看失败原因" />
</template>
<template #default="scope">
<template v-if="scope.row.summaryStatus === '9'">
<el-tooltip placement="top">
<template #content>{{ scope.row.summaryFailReason }}</template>
<dict-tag :options="summary_status" :value="scope.row.summaryStatus" />
</el-tooltip>
</template>
<template v-else>
<dict-tag :options="summary_status" :value="scope.row.summaryStatus" />
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="300">
<template #default="scope">
<el-button icon="View" text type="primary" @click="viewDocument(scope.row)" :disabled="scope.row.sliceStatus !== '1'">文档</el-button>
<el-button icon="Refresh" text type="primary" @click="retry2slice(scope.row)">重试</el-button>
<el-button icon="edit-pen" text type="primary" v-auth="'knowledge_aiDocument_del'" @click="go2slice(scope.row)">切片</el-button>
<el-button icon="delete" text type="primary" v-auth="'knowledge_aiDocument_del'" @click="handleDelete([scope.row.id])">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</div>
<!-- 编辑新增 -->
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
<!-- 文档查看抽屉 -->
<document-drawer
v-model="documentDrawerVisible"
:document-id="selectedDocumentId"
/>
</div>
</template>
<script setup lang="ts" name="systemAiDocument">
import { BasicTableProps, useTable } from '/@/hooks/table';
import { fetchList, delObjs, retrySlice, retryIssue } from '/@/api/knowledge/aiDocument';
import { useMessage, useMessageBox } from '/@/hooks/message';
import { useDict } from '/@/hooks/dict';
import { fetchDataList } from '/@/api/knowledge/aiDataset';
import DocumentDrawer from '../aiSlice/components/DocumentDrawer.vue';
const route = useRoute();
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
// 定义查询字典
const { source_type, slice_status, summary_status } = useDict('yes_no_type', 'source_type', 'slice_status', 'summary_status');
const router = useRouter();
// 定义变量内容
const formDialogRef = ref();
// 搜索变量
const queryRef = ref();
const showSearch = ref(true);
// 多选变量
const selectObjs = ref([]) as any;
const multiple = ref(true);
const state: BasicTableProps = reactive<BasicTableProps>({
createdIsNeed: false,
queryForm: {},
pageList: fetchList,
});
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state);
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
queryRef.value?.resetFields();
state.queryForm = {};
// 清空多选
selectObjs.value = [];
getDataList();
};
// 导出excel
const exportExcel = () => {
downBlobFile('/knowledge/aiDocument/export', Object.assign(state.queryForm, { ids: selectObjs }), 'aiDocument.xlsx');
};
// 多选事件
const selectionChangHandle = (objs: { id: string }[]) => {
selectObjs.value = objs.map(({ id }) => id);
multiple.value = !objs.length;
};
// 删除操作
const handleDelete = async (ids: string[]) => {
try {
await useMessageBox().confirm('此操作将永久删除');
} catch {
return;
}
try {
await delObjs(ids);
getDataList();
useMessage().success('删除成功');
} catch (err: any) {
useMessage().error(err.msg);
}
};
const datasetList = ref([]);
/**
* 获取知识库列表数据
* 通过API调用获取所有可用的知识库
*/
const getDatasetList = async () => {
const { data } = await fetchDataList();
datasetList.value = data;
};
onMounted(async () => {
await getDatasetList();
if (route.query.datasetId) {
state.queryForm.datasetId = route.query.datasetId;
}
// 查询表格数据
await getDataList();
});
/**
* 重新执行文档切片
* @param document 需要重新切片的文档对象
*/
const retry2slice = async (document: any) => {
try {
await useMessageBox().confirm('此操作将重新切片,删除原有切片数据');
} catch {
return;
}
try {
await retrySlice(document);
useMessage().success('操作成功,稍后请刷新列表查看');
} catch (err: any) {
useMessage().error(err.msg);
}
};
// 文档查看抽屉
const documentDrawerVisible = ref(false);
const selectedDocumentId = ref('');
/**
* 跳转到文档切片页面
* @param document 要查看/编辑切片的文档对象
*/
const go2slice = (document: any) => {
router.push({
path: '/knowledge/aiSlice/index',
query: {
documentId: document.id,
},
});
};
/**
* 查看完整文档
* @param document 要查看的文档对象
*/
const viewDocument = (document: any) => {
// 只有切片成功的文档才能查看
if (document.sliceStatus !== '1') {
useMessage().warning('只有切片成功的文档才能查看');
return;
}
selectedDocumentId.value = document.id;
documentDrawerVisible.value = true;
};
</script>

View File

@@ -0,0 +1,79 @@
<template>
<el-col class="mb20">
<el-form-item label="文件名" prop="name">
<el-input placeholder="请输入文件名" v-model="modelValue.name" />
</el-form-item>
</el-col>
<el-col class="mb20">
<el-form-item label="网址" prop="url">
<el-input placeholder="请输入网址" v-model="modelValue.url">
<template #append>
<el-button :icon="Search" :loading="loading" @click="handleParse"></el-button>
<el-divider direction="vertical" class="mx-4" />
<el-button :icon="Setting" @click="handleToggleSettings"></el-button>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col class="mb20" v-if="showSettings">
<el-form-item prop="settings">
<template #label>目标<tip content="请输入目标元素class例如 .class1"></tip> </template>
<el-input show-word-limit v-model="modelValue.settings" />
</el-form-item>
</el-col>
<el-col class="mb20">
<el-form-item label="内容" prop="content">
<el-input type="textarea" rows="10" maxlength="3000" show-word-limit v-model="modelValue.content" />
</el-form-item>
</el-col>
</template>
<script setup lang="ts">
import { ref, PropType } from 'vue';
import { Search, Setting } from '@element-plus/icons-vue';
// @ts-ignore
import JsonEditor from '@axolo/json-editor-vue';
import { crawleObj } from '/@/api/knowledge/aiDocument';
import { ElMessage } from 'element-plus';
const props = defineProps({
modelValue: {
type: Object as PropType<any>,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const showSettings = ref(false);
const loading = ref(false);
const handleToggleSettings = () => {
showSettings.value = !showSettings.value;
};
const handleParse = async () => {
if (loading.value) return;
if (!props.modelValue.url) {
ElMessage.warning('请输入网址');
return;
}
loading.value = true;
try {
const payload = {
url: props.modelValue.url,
settings: props.modelValue.settings,
};
const { data } = await crawleObj(payload);
props.modelValue.content = data;
emit('update:modelValue', { ...props.modelValue, content: data });
ElMessage.success('解析成功');
} catch (error) {
ElMessage.error('解析失败,请检查后台服务或接口定义');
} finally {
loading.value = false;
}
};
</script>

View File

@@ -0,0 +1,29 @@
<template>
<el-col class="mb20">
<el-form-item label="资料" prop="files">
<upload-file :limit="fileLimit" :fileSize="fileSize" :fileType="fileType" @change="handleFileChange" />
</el-form-item>
</el-col>
</template>
<script setup lang="ts">
const emit = defineEmits(['update:modelValue']);
const props = defineProps({
modelValue: {
type: Object,
},
});
// 单此上传文件数量限制
const fileLimit = ref(5);
// 单个文件大小限制
const fileSize = ref(10);
// 文件类型限制
const fileType = ref(['jpeg', 'png', 'jpg', 'gif', 'md', 'doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx']);
const handleFileChange = (fileNames: string, fileList: any[]) => {
emit('update:modelValue', fileList);
};
</script>

View File

@@ -0,0 +1,34 @@
<template>
<el-col class="mb20">
<el-form-item label="资料" prop="files">
<upload-file
:limit="1"
@change="handleFileChange"
:fileType="['xlsx']"
/>
<a class="link link-primary" @click="downloadTemplate">Q&A Excel 模板下载</a>
</el-form-item>
</el-col>
</template>
<script setup lang="ts">
import { PropType } from 'vue';
import { downBlobFile } from "/@/utils/other";
const props = defineProps({
modelValue: {
type: Object as PropType<any>,
required: true
}
});
const emit = defineEmits(['update:modelValue']);
const handleFileChange = (fileNames: string, fileList: any[]) => {
emit('update:modelValue', fileList);
}
const downloadTemplate = () => {
downBlobFile('/admin/sys-file/local/file/qa.xlsx', {}, 'Q&A.xlsx');
};
</script>

View File

@@ -0,0 +1,30 @@
<template>
<el-col class="mb20">
<el-form-item label="文件名" prop="name">
<el-input placeholder="请输入文件名" v-model="modelValue.name" />
</el-form-item>
</el-col>
<el-col class="mb20">
<el-form-item label="内容" prop="content">
<ai-editor
v-model="modelValue.content"
output="text"
placeholder="选择输入文本,即可调用 AI 辅助功能"
:minHeight="400"
/>
</el-form-item>
</el-col>
</template>
<script setup lang="ts">
import { PropType } from 'vue';
defineProps({
modelValue: {
type: Object as PropType<any>,
required: true,
},
});
defineEmits(['update:modelValue']);
</script>

View File

@@ -0,0 +1,11 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-form>
<ai-editor width="50%" :min-height="800" />
</el-form>
</div>
</div>
</template>
<script setup lang="ts" name="knowledgeAiEditor"></script>

View File

@@ -0,0 +1,317 @@
<template>
<el-dialog width="40%" :title="form.storeId ? '编辑' : '新增'" v-model="visible" :close-on-click-modal="false" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="90px" v-loading="loading">
<el-row :gutter="24">
<el-col :span="24" class="mb20">
<el-form-item label="类型" prop="storeType">
<el-select v-model="form.storeType" placeholder="请选择类型">
<el-option :label="item.label" :value="item.value" v-for="(item, index) in embed_store_type" :key="index"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入名称" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20" v-if="form.storeType !== 'qdrant' && form.storeType !== 'redis' && form.storeType !== 'pgvector'">
<el-form-item label="URI" prop="uri">
<el-input v-model="form.uri" placeholder="请输入链接地址" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20" v-if="form.storeType === 'qdrant' || form.storeType === 'redis' || form.storeType === 'pgvector'">
<el-form-item label="Host" prop="host">
<el-input v-model="form.host" placeholder="请输入Host" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20" v-if="form.storeType === 'qdrant' || form.storeType === 'redis' || form.storeType === 'pgvector'">
<el-form-item label="端口" prop="port">
<el-input-number v-model="form.port" placeholder="请输入端口" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20" v-if="form.storeType !== 'pgvector'">
<el-form-item label="密钥" prop="apiKey">
<el-input v-model="form.apiKey" placeholder="请输入密钥" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20" v-if="form.storeType === 'milvus'">
<el-form-item label="数据库" prop="extData">
<el-input v-model="form.extData" placeholder="请输入数据库" />
</el-form-item>
</el-col>
<!-- PgVector 特有配置 -->
<template v-if="form.storeType === 'pgvector'">
<el-col :span="24" class="mb20">
<el-form-item label="用户名" prop="pgUsername">
<el-input v-model="form.pgUsername" placeholder="请输入用户名" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="密码" prop="pgPassword">
<el-input v-model="form.pgPassword" type="password" placeholder="请输入密码" show-password />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="数据库" prop="pgDatabase">
<el-input v-model="form.pgDatabase" placeholder="请输入数据库名" />
</el-form-item>
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="维度" prop="pgDimension">
<el-input-number v-model="form.pgDimension" :min="1" :max="4096" placeholder="请输入向量维度" />
</el-form-item>
</el-col>
</template>
<el-col :span="24" class="mb20">
<el-form-item prop="useTls" v-if="form.storeType === 'qdrant'">
<template #label> TLS<tip content="HTTPS安全认证" /> </template>
<el-radio-group v-model="form.useTls">
<el-radio :key="index" :label="item.value" border v-for="(item, index) in yes_no_type">{{ item.label }} </el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="AiEmbedStoreDialog">
import { useDict } from '/@/hooks/dict';
import { useMessage } from '/@/hooks/message';
import { getObj, addObj, putObj, validateExist } from '/@/api/knowledge/aiEmbedStore';
const emit = defineEmits(['refresh']);
// 定义变量内容
const dataFormRef = ref();
const visible = ref(false);
const loading = ref(false);
// 定义字典
const { embed_store_type, yes_no_type } = useDict('embed_store_type', 'yes_no_type');
// 提交表单数据
const form = reactive({
storeId: '',
name: '',
storeType: 'milvus',
host: '127.0.0.1',
port: 6334,
uri: 'http://127.0.0.1:19530',
apiKey: '',
extData: 'default',
useTls: '0',
// PgVector 特有字段
pgUsername: 'postgres',
pgPassword: 'postgres',
pgDatabase: 'database',
pgDimension: 4096,
});
// 监听 storeType 变化,设置默认端口
watch(
() => form.storeType,
(newType) => {
if (newType === 'pgvector') {
form.port = 5432;
} else if (newType === 'qdrant') {
form.port = 6334;
} else if (newType === 'redis') {
form.port = 6379;
}
}
);
// 定义校验规则
const dataRules = ref({
name: [
{ required: true, message: '名称不能为空', trigger: 'blur' },
{ max: 64, message: '长度不能超过64个字符', trigger: 'blur' },
{
validator: (rule: any, value: any, callback: any) => {
validateExist(rule, value, callback, form.storeId !== '');
},
trigger: 'blur',
},
],
storeType: [{ required: true, message: '类型不能为空', trigger: 'blur' }],
host: [
{ required: true, message: 'Host不能为空', trigger: 'blur' },
{ max: 255, message: '长度不能超过255个字符', trigger: 'blur' },
],
port: [
{ required: true, message: '端口不能为空', trigger: 'blur' },
{ type: 'number', max: 65535, message: '端口不能超过65535', trigger: 'blur' },
],
uri: [
{ required: true, message: '地址不能为空', trigger: 'blur' },
{ max: 255, message: '长度不能超过255个字符', trigger: 'blur' },
{
validator: (rule: any, value: any, callback: any) => {
// URI变化时重新验证数据库字段
nextTick(() => {
if (dataFormRef.value) {
dataFormRef.value.validateField('extData');
}
});
callback();
},
trigger: 'blur',
},
],
extData: [
{ max: 255, message: '长度不能超过255个字符', trigger: 'blur' },
{
validator: (rule: any, value: any, callback: any) => {
// 检查URI是否包含.zilliz.com.cn在线服务
if (form.uri && form.uri.includes('.zilliz.com.cn')) {
if (!value) {
callback(new Error('使用Zilliz在线服务时数据库字段不能为空'));
return;
}
// 检查数据库字段格式是否为 db_xxxx
const dbPattern = /^db_\w+$/;
if (!dbPattern.test(value)) {
callback(new Error('使用Zilliz在线服务时数据库字段必须是 db_id 的形式'));
return;
}
}
callback();
},
trigger: 'blur',
},
],
// PgVector 校验规则
pgUsername: [
{ required: true, message: '用户名不能为空', trigger: 'blur' },
{ max: 64, message: '长度不能超过64个字符', trigger: 'blur' },
],
pgPassword: [
{ required: true, message: '密码不能为空', trigger: 'blur' },
{ max: 255, message: '长度不能超过255个字符', trigger: 'blur' },
],
pgDatabase: [
{ required: true, message: '数据库名不能为空', trigger: 'blur' },
{ max: 64, message: '长度不能超过64个字符', trigger: 'blur' },
],
pgDimension: [
{ required: true, message: '维度不能为空', trigger: 'blur' },
{ type: 'number', min: 1, max: 4096, message: '维度必须在1-4096之间', trigger: 'blur' },
],
});
// 打开弹窗
const openDialog = (id: string) => {
visible.value = true;
form.storeId = '';
form.extData = '';
// 重置表单数据
nextTick(() => {
dataFormRef.value?.resetFields();
});
// 获取aiEmbedStore信息
if (id) {
form.storeId = id;
getaiEmbedStoreData(id);
}
};
// 提交
const onSubmit = async () => {
const valid = await dataFormRef.value.validate().catch(() => {});
if (!valid) return false;
try {
loading.value = true;
// 修复TypeScript错误正确处理apiKey的类型
const submitForm = { ...form };
// 处理 pgvector 类型的特殊逻辑
if (form.storeType === 'pgvector') {
// 将 pgvector 的配置信息存储到 extData 中
const pgConfig = {
username: form.pgUsername,
password: form.pgPassword,
database: form.pgDatabase,
dimension: form.pgDimension,
};
submitForm.extData = JSON.stringify(pgConfig);
// 清空不需要的字段
submitForm.apiKey = '';
} else {
// 处理其他类型的 apiKey
if (submitForm.apiKey?.includes('***')) {
submitForm.apiKey = '';
}
}
// 移除 pgvector 特有的临时字段
delete (submitForm as any).pgUsername;
delete (submitForm as any).pgPassword;
delete (submitForm as any).pgDatabase;
delete (submitForm as any).pgDimension;
form.storeId ? await putObj(submitForm) : await addObj(submitForm);
useMessage().success(form.storeId ? '修改成功' : '添加成功');
visible.value = false;
emit('refresh');
} catch (err: any) {
useMessage().error(err.msg);
} finally {
loading.value = false;
}
};
// 初始化表单数据
const getaiEmbedStoreData = (id: string) => {
// 获取数据
loading.value = true;
getObj({ storeId: id })
.then((res: any) => {
Object.assign(form, res.data);
// 如果是 pgvector 类型,解析 extData 中的配置
if (form.storeType === 'pgvector' && form.extData) {
try {
const pgConfig = JSON.parse(form.extData);
form.pgUsername = pgConfig.username || 'postgres';
form.pgPassword = pgConfig.password || 'postgres';
form.pgDatabase = pgConfig.database || 'database';
form.pgDimension = pgConfig.dimension || 4096;
} catch (e) {
// 解析失败时使用默认值
form.pgUsername = 'postgres';
form.pgPassword = 'postgres';
form.pgDatabase = 'database';
form.pgDimension = 4096;
}
}
})
.finally(() => {
loading.value = false;
});
};
// 暴露变量
defineExpose({
openDialog,
});
</script>

View File

@@ -0,0 +1,139 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-row v-show="showSearch">
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
<el-form-item label="名称" prop="name">
<el-input placeholder="请输入名称" v-model="state.queryForm.name" />
</el-form-item>
<el-form-item label="类型" prop="storeType">
<el-select v-model="state.queryForm.storeType" placeholder="请选择类型">
<el-option :label="item.label" :value="item.value" v-for="(item, index) in embed_store_type" :key="index"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="getDataList"> 查询 </el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="formDialogRef.openDialog()" v-auth="'knowledge_aiEmbedStore_add'">
</el-button>
<el-button plain :disabled="multiple" icon="Delete" type="primary" v-auth="'knowledge_aiEmbedStore_del'" @click="handleDelete(selectObjs)">
删除
</el-button>
<right-toolbar
v-model:showSearch="showSearch"
:export="'knowledge_aiEmbedStore_export'"
@exportExcel="exportExcel"
class="ml10 mr20"
style="float: right"
@queryTable="getDataList"
></right-toolbar>
</div>
</el-row>
<el-table
:data="state.dataList"
v-loading="state.loading"
border
:cell-style="tableStyle.cellStyle"
:header-cell-style="tableStyle.headerCellStyle"
@selection-change="selectionChangHandle"
@sort-change="sortChangeHandle"
>
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="name" label="名称" show-overflow-tooltip align="center" />
<el-table-column prop="storeType" label="类型" show-overflow-tooltip align="center">
<template #default="scope">
<dict-tag :options="embed_store_type" :value="scope.row.storeType"></dict-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" show-overflow-tooltip align="center" />
<el-table-column label="操作" width="150" align="center">
<template #default="scope">
<el-button icon="edit-pen" text type="primary" v-auth="'knowledge_aiEmbedStore_edit'" @click="formDialogRef.openDialog(scope.row.storeId)"
>编辑</el-button
>
<el-button icon="delete" text type="primary" v-auth="'knowledge_aiEmbedStore_del'" @click="handleDelete([scope.row.storeId])"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</div>
<!-- 编辑新增 -->
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
</div>
</template>
<script setup lang="ts" name="systemAiEmbedStore">
import { BasicTableProps, useTable } from '/@/hooks/table';
import { fetchList, delObjs } from '/@/api/knowledge/aiEmbedStore';
import { useMessage, useMessageBox } from '/@/hooks/message';
import { useDict } from '/@/hooks/dict';
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
// 定义查询字典
const { embed_store_type } = useDict('embed_store_type');
// 定义变量内容
const formDialogRef = ref();
// 搜索变量
const queryRef = ref();
const showSearch = ref(true);
// 多选变量
const selectObjs = ref([]) as any;
const multiple = ref(true);
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {},
pageList: fetchList,
});
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state);
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
queryRef.value?.resetFields();
// 清空多选
selectObjs.value = [];
getDataList();
};
// 导出excel
const exportExcel = () => {
downBlobFile('/knowledge/aiEmbedStore/export', Object.assign(state.queryForm, { ids: selectObjs }), 'aiEmbedStore.xlsx');
};
// 多选事件
const selectionChangHandle = (objs: { storeId: string }[]) => {
selectObjs.value = objs.map(({ storeId }) => storeId);
multiple.value = !objs.length;
};
// 删除操作
const handleDelete = async (ids: string[]) => {
try {
await useMessageBox().confirm('此操作将永久删除');
} catch {
return;
}
try {
await delObjs(ids);
getDataList();
useMessage().success('删除成功');
} catch (err: any) {
useMessage().error(err.msg);
}
};
</script>

View File

@@ -0,0 +1,132 @@
<template>
<el-dialog
:model-value="modelValue"
@update:model-value="(val: boolean) => emit('update:modelValue', val)"
title="API 信息"
width="600px"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<div class="bg-white rounded-lg shadow-md api-info">
<div class="mb-4">
<div class="p-4 bg-gray-50 rounded-lg border border-gray-200 shadow-sm">
<div class="mb-4">
<div class="flex justify-between items-center mb-2">
<span class="text-lg font-semibold text-gray-800">接口地址</span>
</div>
<div class="pb-2 text-base text-gray-600 border-b border-gray-300">
{{ apiUrl }}
</div>
</div>
<div class="mb-4">
<div class="flex justify-between items-center mb-2">
<span class="text-lg font-semibold text-gray-800">请求方法</span>
</div>
<div class="text-base text-gray-600">POST</div>
</div>
<div class="mb-4">
<div class="flex justify-between items-center mb-2">
<span class="text-lg font-semibold text-gray-800">请求头</span>
</div>
<div class="text-base text-gray-600" v-html="headers"></div>
</div>
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-lg font-semibold text-gray-800">请求体</span>
</div>
<div class="text-base text-gray-600">
{{ requestBody }}
</div>
</div>
</div>
<div class="mt-4">
<div class="flex justify-between items-center mb-2">
<span class="font-medium">CURL 命令</span>
<el-button type="primary" link @click="copyText(curlCommand)">复制</el-button>
</div>
<div class="w-full mockup-code">
<pre data-prefix=""> <code >{{ curlCommand }}</code> </pre>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { ElMessage } from 'element-plus';
import { Session } from '/@/utils/storage';
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
flowId: {
type: String,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const isMicro = import.meta.env.VITE_IS_MICRO === 'true' ? true : false;
const apiUrl = computed(() => `${window.location.origin}/api/${isMicro ? 'knowledge' : 'admin'}/aiFlow/execute`);
const headers = computed(
() =>
`Content-Type: application/json<br>
Authorization: Bearer ${Session.getToken()}`
);
const requestBody = computed(() =>
JSON.stringify(
{
id: props.flowId,
params: {},
envs: {},
},
null,
2
)
);
const curlCommand = computed(
(): string =>
`curl -X POST '${apiUrl.value}' \\
--header 'Authorization: Bearer ${Session.getToken()}' \\
--header 'Content-Type: application/json' \\
--data-raw '{
"id": "${props.flowId}",
"params": {},
"envs": {}
}'`
);
const copyText = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
ElMessage.success('复制成功');
} catch (err) {
ElMessage.error('复制失败');
}
};
const handleClose = () => {
emit('update:modelValue', false);
};
</script>
<style lang="scss" scoped>
.api-info {
code {
font-family: Monaco, monospace;
font-size: 0.9em;
}
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<div v-if="visible"
class="context-menu"
:style="{
position: 'fixed',
left: position.x + 'px',
top: position.y + 'px',
zIndex: 1000
}">
<!-- <div class="menu-item"
@click="handleCommand('addNode')">
<el-icon class="menu-icon">
<Plus />
</el-icon>
新增节点
</div> -->
<div class="menu-item"
@click="handleCommand('importDSL')">
<el-icon class="menu-icon">
<Upload />
</el-icon>
导入DSL
</div>
<div class="menu-item"
@click="handleCommand('exportDSL')">
<el-icon class="menu-icon">
<Download />
</el-icon>
导出DSL
</div>
</div>
</template>
<script>
import { ref, defineComponent, onMounted, onBeforeUnmount } from 'vue'
import { Plus, Upload, Download } from '@element-plus/icons-vue'
export default defineComponent({
name: 'CanvasContextMenu',
components: {
Plus,
Upload,
Download
},
props: {
visible: {
type: Boolean,
default: false
},
position: {
type: Object,
default: () => ({ x: 0, y: 0 })
}
},
emits: ['update:visible', 'add-node', 'import-dsl', 'export-dsl'],
setup (props, { emit }) {
// 处理菜单命令
const handleCommand = (command) => {
switch (command) {
case 'addNode':
emit('add')
break
case 'importDSL':
emit('import')
break
case 'exportDSL':
emit('export')
break
}
emit('update:visible', false)
}
// 点击外部关闭菜单的处理函数
const handleClickOutside = (e) => {
const contextMenu = document.querySelector('.context-menu')
if (contextMenu && !contextMenu.contains(e.target)) {
emit('update:visible', false)
}
}
// 组件挂载时添加点击事件监听
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
// 组件销毁前移除事件监听
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
return {
handleCommand,
}
}
})
</script>
<style lang="scss" scoped>
.context-menu {
opacity: 0.9;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.05);
padding: 6px 0;
min-width: 180px;
border: 1px solid rgba(0, 0, 0, 0.06);
animation: menuFadeIn 0.15s ease-out;
}
.menu-item {
padding: 8px 16px;
display: flex;
align-items: center;
cursor: pointer;
transition: all 0.2s ease;
color: rgb(71 84 103 / 1);
font-size: 13px;
white-space: nowrap;
.menu-icon {
margin-right: 8px;
font-size: 16px;
}
&:hover {
background-color: #f8fafc;
transform: translateX(2px);
}
}
.node-types {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 16px;
.el-radio {
margin-right: 20px;
margin-bottom: 10px;
}
}
@keyframes menuFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
:deep(.el-dialog) {
border-radius: 8px;
overflow: hidden;
.el-dialog__header {
margin: 0;
padding: 16px 20px;
border-bottom: 1px solid #e5e7eb;
}
.el-dialog__body {
padding: 20px;
}
.el-dialog__footer {
padding: 16px 20px;
border-top: 1px solid #e5e7eb;
}
}
</style>

View File

@@ -0,0 +1,359 @@
<template>
<el-drawer v-model="visible" :title="'流程运行' + id" :size="500" @close="$emit('close')" direction="rtl" class="chat-drawer">
<div class="chat-container">
<!-- 参数填写区域 -->
<div class="message system-message" v-if="hasStartParams">
<div class="message-content">
<div class="param-list">
<template v-for="(param, index) in startParams">
<div :key="index" v-if="param.type != 'message'" class="param-item">
<div class="param-label" :class="{ required: param.required }">
{{ param.name }}
</div>
<div class="param-content">
<input
v-if="param.inputType === 'input'"
v-model="param.value"
class="param-input"
:class="{ error: showError && param.required && !param.value }"
:placeholder="'请输入' + param.name"
/>
<input
v-else-if="param.inputType === 'number'"
type="number"
v-model.number="param.value"
class="param-input"
:class="{ error: showError && param.required && !param.value }"
:placeholder="'请输入' + param.name"
/>
<textarea
v-else-if="param.inputType === 'textarea'"
v-model="param.value"
class="param-textarea"
:class="{ error: showError && param.required && !param.value }"
:placeholder="'请输入' + param.name"
rows="3"
></textarea>
<select
v-else-if="param.inputType === 'select'"
v-model="param.value"
class="param-select"
:class="{ error: showError && param.required && !param.value }"
>
<option value="">请选择{{ param.name }}</option>
<option v-for="option in param.options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- 聊天消息区域 -->
<ChatMessage :messages="messageList" ref="chatMessages" />
<!-- 底部输入区域 -->
<div class="chat-input">
<div class="input-area">
<textarea
v-model="message"
class="chat-textarea"
:rows="1"
:disabled="parent.isRunning"
placeholder="输入消息提问Enter 发送Shift + Enter 换行"
@keyup.enter="handleSend"
></textarea>
<el-button
class="btn-send"
type="primary"
@click.stop="handleSend"
:disabled="parent.isRunning"
:title="parent.isRunning ? '等待回复...' : '发送'"
>
<el-icon>
<Position />
</el-icon>
</el-button>
</div>
</div>
</div>
</el-drawer>
</template>
<script>
import { Close, Loading, Check, CircleClose, ArrowRight, Position } from '@element-plus/icons-vue';
import NodeList from './components/NodeList.vue';
import ChatMessage from './components/ChatMessage.vue';
export default {
name: 'ChatPanel',
inject: ['parent'],
components: {
ChatMessage,
Close,
Loading,
Check,
CircleClose,
ArrowRight,
Position,
NodeList,
},
props: {
modelValue: {
type: Boolean,
default: false,
},
id: {
type: [String, Number],
default: '',
},
executionNodes: {
type: Array,
default: () => [],
},
finalResult: {
type: Object,
default: null,
},
executionTime: {
type: Number,
default: 0,
},
totalTokens: {
type: Number,
default: 0,
},
startParams: {
type: Array,
default: () => [],
},
},
data() {
return {
showError: false,
message: '',
chatHistory: [],
};
},
computed: {
visible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
hasStartParams() {
// 过滤掉type为message的参数,只返回其他类型参数的长度
return this.startParams && this.startParams.filter((param) => param.type !== 'message').length > 0;
},
messageList() {
let message = [];
this.chatHistory.forEach((item) => {
message.push(item);
});
if (this.executionNodes.length > 0 && this.parent.isRunning) {
message.push({
role: 'assistant',
nodes: this.executionNodes,
});
}
return message;
},
},
methods: {
handleSend() {
if (this.parent.isRunning || !this.message.trim()) return;
let messageObj = this.startParams.find((param) => param.type == 'message');
messageObj.value = this.message;
const hasError = this.startParams.some((param) => param.required && !param.value);
this.showError = hasError;
if (hasError) {
return;
}
this.chatHistory.push({
role: 'user',
content: this.message,
});
this.$emit('run', this.startParams);
this.message = '';
this.showError = false;
},
scrollToBottom() {
this.$nextTick(() => {
const chatMessages = this.$refs.chatMessages.$el;
if (chatMessages) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
});
},
formatTotalTime(time) {
if (!time) return '0ms';
return `${Number(time).toFixed(3)}ms`;
},
},
watch: {
executionNodes: {
handler(node) {
this.scrollToBottom();
},
deep: true,
},
finalResult: {
handler(val) {
this.chatHistory.push({
role: 'assistant',
nodes: this.executionNodes,
result: val.result,
});
this.parent.isRunning = false;
},
deep: true,
},
'parent.isRunning'(newVal) {
if (!newVal) {
this.scrollToBottom();
}
},
},
};
</script>
<style scoped lang="scss">
@use './styles/flow.scss';
.chat-drawer {
:deep(.el-drawer__body) {
padding: 0;
}
}
.chat-container {
box-sizing: border-box;
height: calc(100vh - 80px);
position: relative;
}
.param-title {
font-weight: 500;
margin-bottom: 12px;
color: #67c23a;
}
.execution-result {
margin-top: 20px;
padding: 16px;
background: #f8f9fa;
border-radius: 4px;
line-height: 25px;
font-size: 12px;
pre {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
padding: 8px;
border-radius: 4px;
}
}
.chat-input {
position: absolute;
bottom: 0;
width: 100%;
}
.chat-messages {
height: calc(100vh - 150px);
overflow-y: auto;
}
.system-message {
padding: 16px;
.param-list {
margin-bottom: 16px;
}
.param-item {
margin-bottom: 12px;
display: flex;
align-items: center;
.param-label {
width: 100px;
font-size: 14px;
color: #606266;
margin-bottom: 8px;
text-align: right;
}
.param-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
}
.param-input,
.param-select,
.param-textarea {
width: 100%;
padding: 0 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
background-color: #fff;
transition: border-color 0.2s;
outline: none;
box-sizing: border-box;
&:hover {
border-color: #c0c4cc;
}
&:focus {
border-color: #409eff;
}
&::placeholder {
color: #c0c4cc;
}
&.error {
border-color: #f56c6c;
}
}
.param-input,
.param-select {
height: 32px;
line-height: 32px;
}
.param-textarea {
min-height: 80px;
padding: 8px 12px;
line-height: 1.5;
resize: vertical;
}
.error-tip {
font-size: 12px;
color: #f56c6c;
line-height: 1.4;
padding-left: 2px;
}
}
}
</style>

View File

@@ -0,0 +1,451 @@
<template>
<div class="check-list-panel">
<div class="preview-content">
<div class="preview-header">
<div class="header-title">
<span>检查清单({{ validation.errors.length }})</span>
</div>
<el-button
class="close-btn"
type="primary"
link
@click="$emit('close')"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
<div class="preview-body">
<div class="check-list">
<template v-if="validation.errors.length > 0">
<div v-for="(error, index) in validation.errors"
:key="index"
class="check-item">
<div class="item-icon warning">
<el-icon><Warning /></el-icon>
</div>
<div class="item-content">
<div class="item-type">{{ getErrorType(error) }}</div>
<div class="item-message">{{ getErrorMessage(error) }}</div>
</div>
</div>
</template>
<div v-else
class="check-item success">
<div class="item-icon success">
<el-icon><Select /></el-icon>
</div>
<div class="item-content">
<div class="item-type success">检测通过</div>
<div class="item-message">工作流程检测已通过可以发布</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Warning, Close, Select } from '@element-plus/icons-vue'
export default {
name: 'CheckListPanel',
inject: ['parent'],
components: {
Warning,
Close,
Select
},
props: {
validation: {
type: Object,
required: true
}
},
emits: ['update:validation', 'close'],
data () {
return {
showCheckList: false
}
},
watch: {
'parent.nodes': {
handler: 'checkChanges',
deep: true,
immediate: true
},
'parent.connections': {
handler: 'checkChanges',
deep: true,
immediate: true
}
},
methods: {
checkChanges () {
const newValidation = this.validateWorkflow()
this.$emit('update:validation', newValidation)
},
validateWorkflow () {
const validation = {
isValid: true,
errors: [],
warnings: []
};
try {
if (!this.parent.nodes || this.parent.nodes.length === 0) {
validation.errors.push({
type: 'error',
message: '工作流中没有节点'
});
validation.isValid = false;
return validation;
}
const startNodes = this.parent.nodes.filter(node => node.type === 'start');
const endNodes = this.parent.nodes.filter(node => node.type === 'end');
if (startNodes.length === 0) {
validation.errors.push({
type: 'error',
message: '工作流缺少开始节点'
});
validation.isValid = false;
} else if (startNodes.length > 1) {
validation.errors.push({
type: 'error',
message: '工作流只能有一个开始节点'
});
validation.isValid = false;
}
if (endNodes.length === 0) {
validation.errors.push({
type: 'error',
message: '工作流缺少结束节点'
});
validation.isValid = false;
}
const nodeConnections = new Map();
this.parent.nodes.forEach(node => {
nodeConnections.set(node.id, {
inbound: [],
outbound: []
});
});
this.parent.connections.forEach(conn => {
const sourceConn = nodeConnections.get(conn.sourceId);
const targetConn = nodeConnections.get(conn.targetId);
if (sourceConn) {
sourceConn.outbound.push(conn.targetId);
}
if (targetConn) {
targetConn.inbound.push(conn.sourceId);
}
});
this.parent.nodes.forEach(node => {
const nodeConn = nodeConnections.get(node.id);
if (node.type === 'start' && nodeConn.inbound.length > 0) {
validation.errors.push({
type: 'error',
message: '开始节点不能有入边连接',
nodeId: node.id
});
validation.isValid = false;
}
if (node.type === 'end' && nodeConn.outbound.length > 0) {
validation.errors.push({
type: 'error',
message: '结束节点不能有出边连接',
nodeId: node.id
});
validation.isValid = false;
}
if (nodeConn.inbound.length === 0 && nodeConn.outbound.length === 0 &&
node.type !== 'start' && node.type !== 'end') {
validation.warnings.push({
type: 'warning',
message: '存在孤立节点',
nodeId: node.id
});
}
});
this.parent.nodes.forEach(node => {
// switch (node.type) {
// case 'http':
// this.validateHttpNode(node, validation);
// break;
// case 'code':
// this.validateCodeNode(node, validation);
// break;
// }
});
if (this.hasCircularDependency()) {
validation.errors.push({
type: 'error',
message: '工作流中存在循环依赖'
});
validation.isValid = false;
}
return validation;
} catch (error) {
console.error('验证工作流时出错:', error);
validation.errors.push({
type: 'error',
message: '验证工作流时出错: ' + error.message
});
validation.isValid = false;
return validation;
}
},
validateHttpNode (node, validation) {
if (!node.url) {
validation.errors.push({
type: 'error',
message: 'HTTP节点缺少URL',
nodeId: node.id
});
validation.isValid = false;
}
if (!node.method) {
validation.errors.push({
type: 'error',
message: 'HTTP节点缺少请求方法',
nodeId: node.id
});
validation.isValid = false;
}
if (node.headers) {
node.headers.forEach((header, index) => {
if (!header.name) {
validation.errors.push({
type: 'error',
message: `HTTP节点的第${index + 1}个请求头缺少名称`,
nodeId: node.id
});
validation.isValid = false;
}
});
}
if (node.method !== 'GET' && node.bodyParams) {
node.bodyParams.forEach((param, index) => {
if (!param.name) {
validation.errors.push({
type: 'error',
message: `HTTP节点的第${index + 1}个请求体参数缺少名称`,
nodeId: node.id
});
validation.isValid = false;
}
});
}
},
validateCodeNode (node, validation) {
if (!node.code) {
validation.errors.push({
type: 'error',
message: '代码节点缺少执行代码',
nodeId: node.id
});
validation.isValid = false;
} else {
try {
new Function(node.code);
} catch (error) {
validation.errors.push({
type: 'error',
message: `代码语法错误: ${error.message}`,
nodeId: node.id
});
validation.isValid = false;
}
}
},
hasCircularDependency () {
const visited = new Set();
const recursionStack = new Set();
const dfs = (nodeId) => {
visited.add(nodeId);
recursionStack.add(nodeId);
const connections = this.parent.connections.filter(conn => conn.sourceId === nodeId);
for (const conn of connections) {
const targetId = conn.targetId;
if (!visited.has(targetId)) {
if (dfs(targetId)) {
return true;
}
} else if (recursionStack.has(targetId)) {
return true;
}
}
recursionStack.delete(nodeId);
return false;
};
for (const node of this.parent.nodes) {
if (!visited.has(node.id)) {
if (dfs(node.id)) {
return true;
}
}
}
return false;
},
getErrorType (error) {
if (error.message.includes('缺少')) return '节点缺失'
if (error.message.includes('未连接')) return '连接错误'
if (error.message.includes('循环')) return '循环依赖'
return '错误'
},
getErrorMessage (error) {
let nodeName = ''
if (error.nodeId) {
const node = this.parent.nodes.find(n => n.id === error.nodeId)
if (node) {
nodeName = node.name || `${node.type}节点`
}
}
if (nodeName) {
return `${nodeName}: ${error.message}`
}
return error.message
}
}
}
</script>
<style lang="scss" scoped>
.check-list-panel {
position: fixed;
top: 80px;
right: 20px;
z-index: 1;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
width: 400px;
}
.preview-content {
position: relative;
width: 100%;
}
.preview-header {
padding: 12px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
display: flex;
flex-direction: column;
gap: 4px;
span:first-child {
font-size: 16px;
font-weight: 500;
color: #111827;
}
}
.subtitle {
font-size: 14px;
color: #6b7280;
}
.preview-body {
padding: 12px;
max-height: 400px;
overflow-y: auto;
}
.check-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.check-item {
display: flex;
gap: 12px;
padding: 12px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
&.success {
background: #f0fdf4;
border-color: #86efac;
}
}
.item-icon {
width: 24px;
height: 24px;
color: #ef4444;
display: flex;
align-items: center;
justify-content: center;
&.warning {
color: #f59e0b;
}
&.success {
color: #22c55e;
}
.el-icon {
font-size: 20px;
}
}
.item-content {
flex: 1;
}
.item-type {
font-size: 14px;
font-weight: 500;
color: #f59e0b;
margin-bottom: 4px;
&.success {
color: #22c55e;
}
}
.item-message {
font-size: 14px;
color: #4b5563;
line-height: 1.4;
}
.close-btn {
padding: 4px;
font-size: 18px;
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<el-dialog title="环境变量设置" v-model="visible" width="650px" class="env-settings-dialog">
<div class="space-y-4">
<!-- 顶部操作区 -->
<div class="flex justify-between items-center pb-3 border-b border-gray-200">
<div class="text-sm text-gray-500">
<span>设置流程中全局可使用的变量</span>
</div>
<el-button
class="flex gap-1 items-center text-white bg-blue-500 rounded-md transition-colors hover:bg-blue-600"
type="primary"
@click="addEnvVariable"
>
<i class="el-icon-plus"></i>
<span>添加</span>
</el-button>
</div>
<!-- 变量列表 -->
<div class="space-y-2">
<transition-group name="env-list">
<div
v-for="(item, index) in parent.env"
:key="index"
class="flex gap-3 items-center p-2 rounded-lg transition-colors group hover:bg-gray-50"
>
<div class="w-2/5">
<el-input v-model="item.name" placeholder="请输入变量名" class="w-full" />
</div>
<div class="w-1/2">
<el-input v-model="item.value" placeholder="请输入变量值" class="w-full" />
</div>
<div class="w-auto">
<el-button icon="delete" class="text-gray-400 transition-colors hover:text-red-500" @click="deleteEnvVariable(index)" />
</div>
</div>
</transition-group>
</div>
<!-- 空状态提示 -->
<div v-if="!parent.env.length" class="py-8">
<el-empty description="暂无环境变量" :image-size="100" class="flex flex-col items-center">
<el-button type="primary" class="mt-4 text-white bg-blue-500 rounded-md transition-colors hover:bg-blue-600" @click="addEnvVariable">
添加第一个环境变量
</el-button>
</el-empty>
</div>
</div>
<!-- 底部操作区 -->
<template #footer>
<div class="flex gap-2 justify-end">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="visible = false">确认</el-button>
</div>
</template>
</el-dialog>
</template>
<script>
export default {
name: 'EnvSettingsPanel',
inject: ['parent'],
props: {
modelValue: {
type: Boolean,
default: false,
},
},
computed: {
visible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
},
data() {
return {
keyPattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
};
},
created() {
// Ensure parent.env is initialized as an array
if (!this.parent.env || !Array.isArray(this.parent.env)) {
this.parent.env = [];
}
},
methods: {
addEnvVariable() {
// Ensure parent.env is an array before pushing
if (!Array.isArray(this.parent.env)) {
this.parent.env = [];
}
this.parent.env.push({
name: '',
value: '',
});
},
deleteEnvVariable(index) {
if (Array.isArray(this.parent.env)) {
this.parent.env.splice(index, 1);
}
},
},
};
</script>
<style lang="scss">
.env-settings-dialog {
/* 基础样式通过 Tailwind 实现 */
/* 动画效果 */
.env-list-enter-active,
.env-list-leave-active {
transition: all 0.3s ease;
}
.env-list-enter-from,
.env-list-leave-to {
opacity: 0;
transform: translateY(-10px);
}
:deep(.el-input) {
.el-input__wrapper {
@apply border border-gray-300 hover:border-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all;
}
}
:deep(.el-button) {
@apply transition-all duration-200;
&.is-disabled {
@apply opacity-50 cursor-not-allowed;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,196 @@
<template>
<!-- 开场白和开场问题编辑器 -->
<el-dialog
v-model="dialogVisible"
title="提示词"
width="600px"
:show-footer="false"
>
<div class="greeting-editor">
<!-- 开场白编辑区 -->
<div class="greeting-section">
<div class="section-title">
<div class="title-with-help">
<span>聊天开场白</span>
<el-tooltip content="开场白会在用户进入对话时首先展示用于介绍AI助手的功能和特点。"
placement="top">
<el-icon class="help-icon">
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</div>
<el-input
v-model="form.greeting"
type="textarea"
:rows="5"
class="greeting-input"
placeholder="在这里编写AI助手的开场白"
/>
</div>
<!-- 开场问题编辑区 -->
<div class="questions-section">
<div class="section-title">
<div class="title-with-help">
<span>开场问题</span>
<div class="question-count">{{ form.questions.length }}/10</div>
<el-tooltip content="设置常见问题示例帮助用户快速开始对话。最多可设置10个问题。"
placement="top">
<el-icon class="help-icon">
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
<el-button
type="primary"
class="add-question"
@click="addQuestion"
:disabled="form.questions.length >= 10"
size="small"
>
<el-icon class="icon-plus">
<Plus />
</el-icon>
添加
</el-button>
</div>
<div class="questions-list">
<div v-for="(question, index) in form.questions"
:key="index"
class="question-item">
<el-input
v-model="question.text"
class="question-input"
:placeholder="'问题 ' + (index + 1)"
/>
<el-button
@click="removeQuestion(index)"
type="danger"
:icon="Delete"
circle
size="small"
/>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { QuestionFilled, Plus, Delete } from '@element-plus/icons-vue'
// 注入parent
const parent = inject('parent')
// 定义组件属性
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
// 定义事件
const emit = defineEmits(['update:modelValue'])
// 对话框显示状态
const dialogVisible = ref(props.modelValue)
const form = ref({
questions: []
})
// 监听modelValue的变化
watch(() => props.modelValue, (newVal) => {
form.value = parent.dsl;
form.value.questions=form.value.questions || []
dialogVisible.value = newVal
})
// 监听dialogVisible的变化
watch(() => dialogVisible.value, (newVal) => {
emit('update:modelValue', newVal)
})
// 添加问题
const addQuestion = () => {
if (form.value.questions.length < 10) {
form.value.questions.push({ text: '' })
}
}
// 删除问题
const removeQuestion = (index) => {
form.value.questions.splice(index, 1)
}
</script>
<style lang="scss" scoped>
.greeting-editor {
.greeting-section,
.questions-section {
margin-bottom: 20px;
}
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.title-with-help {
display: flex;
align-items: center;
gap: 8px;
.help-icon {
font-size: 16px;
color: #909399;
cursor: help;
}
.question-count {
font-size: 12px;
color: #909399;
margin: 0 8px;
}
}
}
.greeting-input {
:deep(.el-textarea__inner) {
min-height: 60px;
max-height: 120px;
}
}
.questions-list {
display: flex;
flex-direction: column;
gap: 10px;
.question-item {
display: flex;
align-items: center;
gap: 8px;
.question-input {
flex: 1;
}
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,174 @@
<template>
<el-dialog
title="数据结构"
v-model="dialogVisible"
width="800px"
class="json-preview-dialog"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<div class="preview-content">
<!-- 顶部标签页 -->
<el-tabs v-model="activeTab" class="preview-tabs">
<el-tab-pane
v-for="tab in tabs"
:key="tab.key"
:label="tab.label"
:name="tab.key"
>
<template #label>
<span>{{ tab.label }}</span>
<el-tag v-if="tab.key === 'nodes'" size="small" type="info" class="ml-2">
{{ nodeCount }}
</el-tag>
</template>
</el-tab-pane>
</el-tabs>
<!-- 代码编辑器 -->
<div class="preview-body">
<code-editor
v-model="currentTabData"
:json="true"
:readonly="false"
theme="nord"
height="400px"
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="copyData">
<el-icon><Document /></el-icon>
复制内容
</el-button>
<el-button @click="dialogVisible = false">关闭</el-button>
</div>
</template>
</el-dialog>
</template>
<script>
import { Document } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import CodeEditor from "/@/views/knowledge/aiFlow/components/CodeEditor.vue";
export default {
name: 'JsonPreviewPanel',
components: {
CodeEditor,
Document
},
inject: ['parent'],
props: {
modelValue: {
type: Boolean,
default: false
}
},
data() {
return {
activeTab: 'all',
tabs: [
{ key: 'all', label: '全部数据' },
{ key: 'nodes', label: '节点数据' },
{ key: 'connections', label: '连线数据' },
{ key: 'execution', label: '执行顺序' }
]
}
},
computed: {
dialogVisible: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
}
},
data() {
return {
nodes: this.parent.nodes,
connections: this.parent.connections,
executionOrder: this.parent.workflowExecutionOrder,
}
},
nodeCount() {
return this.data.nodes ? this.data.nodes.length : 0
},
currentTabData: {
get() {
let data = ''
switch (this.activeTab) {
case 'nodes':
data = this.data.nodes
break
case 'connections':
data = this.data.connections
break
case 'execution':
data = this.data.executionOrder
break
default:
data = this.data
}
return JSON.stringify(data, null, 2)
},
set() {
// 只读模式不需要实现set
}
}
},
methods: {
copyData() {
navigator.clipboard.writeText(this.currentTabData)
.then(() => {
ElMessage({
message: '复制成功',
type: 'success',
duration: 2000
})
})
.catch(err => {
ElMessage({
message: '复制失败',
type: 'error',
duration: 2000
})
console.error('复制失败:', err)
})
}
}
}
</script>
<style lang="scss" scoped>
.json-preview-dialog {
:deep(.el-dialog__body) {
padding: 0;
}
.preview-content {
.preview-tabs {
padding: 0 15px;
}
.preview-body {
padding: 10px 15px;
}
}
.dialog-footer {
padding: 15px;
text-align: right;
}
:deep(.el-tabs__header) {
margin-bottom: 15px;
}
:deep(.el-tag) {
margin-left: 5px;
}
}
</style>

View File

@@ -0,0 +1,329 @@
<!-- 缩略图组件 -->
<template>
<div class="mini-map" :style="{ width: width + 'px', height: height + 'px' }">
<!-- 缩略图容器 -->
<div class="mini-map-container" ref="container">
<!-- 缩略图内容 -->
<div class="mini-map-content"
:style="{ transform: `translate(${contentPosition.x}px, ${contentPosition.y}px) scale(${scale})` }">
<!-- 节点缩略图 -->
<div v-for="node in nodes"
:key="node.id"
class="mini-node"
:style="{
left: `${node.x}px`,
top: `${node.y}px`,
backgroundColor: getNodeColor(node.type)
}">
</div>
<!-- 连线缩略图 -->
<svg class="mini-connections" :style="{ width: `${bounds.width}px`, height: `${bounds.height}px` }">
<path v-for="(conn, index) in connections"
:key="index"
:d="getConnectionPath(conn)"
class="mini-connection-path"/>
</svg>
</div>
<!-- 视口指示器 -->
<div class="viewport-indicator"
:style="{
transform: `translate(${viewportPosition.x}px, ${viewportPosition.y}px)`,
width: `${viewportSize.width}px`,
height: `${viewportSize.height}px`
}"
@mousedown.prevent="startDrag">
</div>
</div>
</div>
</template>
<script>
export default {
name: 'MiniMap',
props: {
// 缩略图宽度
width: {
type: Number,
default: 200
},
// 缩略图高度
height: {
type: Number,
default: 150
},
// 节点数据
nodes: {
type: Array,
default: () => []
},
// 连接数据
connections: {
type: Array,
default: () => []
},
// 画布缩放比例
zoom: {
type: Number,
default: 1
},
// 画布位置
position: {
type: Object,
default: () => ({ x: 0, y: 0 })
},
// 添加容器尺寸属性
containerSize: {
type: Object,
default: () => ({
width: 0,
height: 0
})
}
},
data() {
return {
scale: 0.1,
isDragging: false,
dragStart: { x: 0, y: 0 },
bounds: {
minX: 0,
minY: 0,
width: 3000,
height: 3000
}
}
},
computed: {
// 计算内容位置,使其居中显示
contentPosition() {
const offsetX = (this.width - this.bounds.width * this.scale) / 2
const offsetY = (this.height - this.bounds.height * this.scale) / 2
return {
x: offsetX,
y: offsetY
}
},
// 修改视口位置计算
viewportPosition() {
// 确保位置不会超出边界
const maxX = this.width - this.viewportSize.width
const maxY = this.height - this.viewportSize.height
let x = (-this.position.x * this.scale) + this.contentPosition.x
let y = (-this.position.y * this.scale) + this.contentPosition.y
// 限制在有效范围内
x = Math.max(0, Math.min(x, maxX))
y = Math.max(0, Math.min(y, maxY))
return { x, y }
},
// 修改视口尺寸计算
viewportSize() {
// 计算缩略图内容的实际显示范围
const contentWidth = this.bounds.width * this.scale
const contentHeight = this.bounds.height * this.scale
// 计算视口尺寸比例
const viewportRatioX = this.width / (this.bounds.width / this.zoom)
const viewportRatioY = this.height / (this.bounds.height / this.zoom)
// 确保视口尺寸不会小于最小值或大于缩略图尺寸
return {
width: Math.min(this.width, Math.max(50, contentWidth * viewportRatioX)),
height: Math.min(this.height, Math.max(30, contentHeight * viewportRatioY))
}
}
},
watch: {
// 监听节点变化,重新计算边界和缩放
nodes: {
handler() {
this.$nextTick(this.updateBoundsAndScale)
},
deep: true
}
},
methods: {
// 获取节点颜色
getNodeColor(type) {
const colors = {
start: '#10B981',
end: '#EF4444',
http: '#3B82F6',
switch: '#F59E0B',
code: '#8B5CF6',
db: '#6366F1',
llm: '#EC4899',
notice: '#14B8A6',
question: '#F97316',
default: '#6B7280'
}
return colors[type] || colors.default
},
// 获取连接路径
getConnectionPath(conn) {
const source = this.nodes.find(n => n.id === conn.sourceId)
const target = this.nodes.find(n => n.id === conn.targetId)
if (!source || !target) return ''
const x1 = source.x
const y1 = source.y
const x2 = target.x
const y2 = target.y
// 计算控制点
const dx = Math.abs(x2 - x1) * 0.5
const cp1x = x1 + dx
const cp1y = y1
const cp2x = x2 - dx
const cp2y = y2
return `M ${x1} ${y1} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x2} ${y2}`
},
// 开始拖动视口
startDrag(event) {
this.isDragging = true
const rect = this.$refs.container.getBoundingClientRect()
this.dragStart = {
x: event.clientX - rect.left - this.viewportPosition.x,
y: event.clientY - rect.top - this.viewportPosition.y
}
window.addEventListener('mousemove', this.onDrag)
window.addEventListener('mouseup', this.stopDrag)
},
// 拖动中
onDrag(event) {
if (!this.isDragging) return
const rect = this.$refs.container.getBoundingClientRect()
let x = event.clientX - rect.left - this.dragStart.x
let y = event.clientY - rect.top - this.dragStart.y
// 添加边界限制
const maxX = this.width - this.viewportSize.width
const maxY = this.height - this.viewportSize.height
x = Math.max(0, Math.min(x, maxX))
y = Math.max(0, Math.min(y, maxY))
// 计算相对于内容的位置
const relativeX = (x - this.contentPosition.x) / this.scale
const relativeY = (y - this.contentPosition.y) / this.scale
this.$emit('update:position', {
x: -relativeX,
y: -relativeY
})
},
// 停止拖动
stopDrag() {
this.isDragging = false
window.removeEventListener('mousemove', this.onDrag)
window.removeEventListener('mouseup', this.stopDrag)
},
// 更新边界和缩放
updateBoundsAndScale() {
if (!this.nodes.length) return
// 计算节点边界
const nodePositions = this.nodes.map(node => ({
left: node.x - 100, // 考虑节点宽度
right: node.x + 100,
top: node.y - 50, // 考虑节点高度
bottom: node.y + 50
}))
// 计算整体边界
const minX = Math.min(...nodePositions.map(p => p.left))
const maxX = Math.max(...nodePositions.map(p => p.right))
const minY = Math.min(...nodePositions.map(p => p.top))
const maxY = Math.max(...nodePositions.map(p => p.bottom))
// 添加边距
const PADDING = 100
this.bounds = {
minX: minX - PADDING,
minY: minY - PADDING,
width: maxX - minX + PADDING * 2,
height: maxY - minY + PADDING * 2
}
// 计算合适的缩放比例
const scaleX = this.width / this.bounds.width
const scaleY = this.height / this.bounds.height
this.scale = Math.min(scaleX, scaleY, 0.2) // 限制最大缩放比例为0.2
}
},
mounted() {
this.updateBoundsAndScale()
}
}
</script>
<style lang="scss" scoped>
.mini-map {
position: absolute;
left: 20px;
bottom: 60px;
background: #fff;
border-radius: 3px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
z-index: 1;
.mini-map-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.mini-map-content {
position: absolute;
transform-origin: 0 0;
}
.mini-node {
position: absolute;
width: 20px;
height: 10px;
border-radius: 2px;
transform: translate(-50%, -50%);
}
.mini-connections {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
.mini-connection-path {
fill: none;
stroke: #94a3b8;
stroke-width: 1px;
}
}
.viewport-indicator {
position: absolute;
border: 1px solid #3b82f6;
background: rgba(59, 130, 246, 0.05); // 降低默认透明度,提高对比度
pointer-events: all;
cursor: move;
border-radius: 4px; // 添加圆角
transition: all 0.2s ease; // 添加过渡动画
&:hover {
background: rgba(59, 130, 246, 0.15); // 提高hover时的透明度
border-color: #2563eb; // hover时加深边框颜色
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); // hover时加深阴影
}
&:active {
background: rgba(59, 130, 246, 0.2); // 点击时加深背景色
border-color: #1d4ed8; // 点击时进一步加深边框
}
}
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<div v-if="visible" class="context-menu" :style="menuStyle">
<div v-if="!canAddRight && addPosition === 'right'" class="menu-disabled-item">
<el-icon class="menu-icon"><WarningFilled /></el-icon>
该节点已有子节点
</div>
<div v-else>
<div class="menu-item" v-for="item in nodeTypes" :key="item.type" @click="handleAddNode(item.type)">
<svg-icon :size="24" :class="['menu-icon', 'node-icon', 'node-icon--' + item.type]" :name="`local-${item.type}`" />
{{ item.name }}
</div>
</div>
</div>
</template>
<script>
import { nodeTypes } from './nodes/nodeTypes.ts';
import { WarningFilled } from '@element-plus/icons-vue';
export default {
name: 'NodeContextMenu',
components: {
WarningFilled,
},
props: {
visible: {
type: Boolean,
default: false,
},
position: {
type: Object,
default: () => ({ x: 0, y: 0 }),
},
node: {
type: Object,
default: null,
},
addPosition: {
type: String,
default: '',
},
portIndex: {
type: Number,
default: 0,
},
},
inject: ['parent'],
computed: {
menuStyle() {
return {
left: `${this.position.x}px`,
top: `${this.position.y}px`,
};
},
canAddNode() {
return this.addPosition && this.node;
},
// 检查是否可以向右添加节点
canAddRight() {
if (this.addPosition !== 'right' || !this.node || !this.parent) {
return true;
}
// 获取当前节点已有的连接数量
const existingConnections = this.parent.connections?.filter((conn) => conn.sourceId === this.node.id) || [];
// 如果不是分支节点且已经有连接,则不能向右添加
if (!['switch', 'question'].includes(this.node.type) && existingConnections.length > 0) {
return false;
}
return true;
},
nodeTypes() {
// 如果不能向右添加,返回空数组
if (!this.canAddRight) {
return [];
}
let types = nodeTypes.map((config) => ({
type: config.type,
name: config.name,
}));
if (this.addPosition === 'replace') {
types = types.filter((node) => node.type !== 'start' && node.type !== this.node?.type);
} else {
types = types.filter((node) => node.type !== 'start');
}
return types;
},
},
methods: {
handleAddNode(type) {
this.$emit('add', type);
this.$emit('update:visible', false);
},
handleClickOutside(e) {
if (!this.$el.contains(e.target)) {
this.$emit('update:visible', false);
}
},
},
watch: {
visible(val) {
if (!val) {
this.$emit('update:visible', false);
}
},
},
mounted() {
// 点击外部关闭菜单
document.addEventListener('click', this.handleClickOutside);
},
beforeUnmount() {
document.removeEventListener('click', this.handleClickOutside);
},
};
</script>
<style scoped>
.context-menu {
opacity: 0.9;
position: fixed;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.05);
padding: 6px 0;
min-width: 180px;
z-index: 1000;
border: 1px solid rgba(0, 0, 0, 0.06);
animation: menuFadeIn 0.15s ease-out;
}
.menu-item {
padding: 8px 16px;
display: flex;
align-items: center;
cursor: pointer;
transition: all 0.2s ease;
color: rgb(71 84 103 / 1);
font-size: 13px;
white-space: nowrap;
}
.menu-item:hover {
background-color: #f8fafc;
transform: translateX(2px);
}
.menu-disabled-item {
padding: 8px 16px;
display: flex;
align-items: center;
color: #9ca3af;
font-size: 13px;
white-space: nowrap;
background-color: #f9f9f9;
}
.menu-icon {
padding: 3px;
width: 12px;
height: 12px;
}
.menu-divider {
height: 1px;
background-color: #e5e7eb;
margin: 6px 0;
}
@keyframes menuFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>

View File

@@ -0,0 +1,273 @@
<template>
<div
:id="node.id"
:ref="node.id"
class="workflow-node"
:class="[
node.type,
{
'node-selected': isSelected,
},
]"
:style="nodeStyle"
@mouseenter="$emit('node-mouseenter', node)"
@mouseleave="$emit('node-mouseleave', node)"
@mousedown="handleMouseDown"
@mouseover="handleMouseOver"
@click="handleNodeClick"
@contextmenu="handleContextMenu"
>
<!-- 右上角菜单按钮 -->
<div class="more-btn" @click.stop="$emit('showContextMenu', $event, node)">
<i class="more-icon"></i>
</div>
<template v-if="node.canBeTarget">
<!-- 左侧添加按钮 -->
<div :id="`${node.id}-input`" class="node-port node-port-input add-btn left-btn" @click.stop="$emit('showMenu', $event, node, 'left')">+</div>
</template>
<div class="node-content">
<div class="node-header">
<svg-icon :size="24" :class="['node-icon', 'node-icon--' + node.type]" :name="`local-${node.type}`" />
<div class="node-title">{{ node.title || node.name }}</div>
</div>
<!-- 节点内容 -->
<component :is="nodeComponent" :node="node" />
</div>
<template v-if="node.canBeSource">
<!-- 右侧添加按钮 -->
<div
v-for="(port, index) in outputPorts"
:key="index"
:id="`${node.id}-output-${index}`"
class="node-port node-port-output add-btn right-btn"
:style="getStyle(index, outputPorts.length)"
:data-port-name="port.name"
@click.stop="$emit('showMenu', $event, node, 'right', index)"
>
+
</div>
</template>
</div>
</template>
<script>
// 使用 import.meta.glob 动态导入所有节点组件
const modules = import.meta.glob('./nodes/*.vue', { eager: true });
export default {
name: 'WorkflowNode',
inject: ['parent'],
props: {
node: {
type: Object,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
},
computed: {
nodeStyle() {
return {
left: `${this.node.x}px`,
top: `${this.node.y}px`,
};
},
nodeComponent() {
// 从文件名中提取组件类型
const nodeName = `${this.node.type.charAt(0).toUpperCase()}${this.node.type.slice(1)}Node`;
// 查找对应的面板组件
const node = Object.values(modules).find((module) => module.default.name === nodeName);
return node?.default;
},
// 添加输出端口计算属性
outputPorts() {
// 根据节点类型返回不同数量的端口
// 根据节点类型返回对应的端口配置
const portConfigs = {
question: () => {
const length = this.node.questionParams?.categories?.length || 1;
return Array.from({ length }, (_, i) => ({ name: this.node.questionParams?.categories?.[i]?.name || `事件${i + 1}` }));
},
switch: () => {
const length = this.node.switchParams?.cases?.length || 1;
return Array.from({ length }, (_, i) => ({ name: this.node.switchParams?.cases?.[i]?.name || `事件${i + 1}` }));
},
default: () => [{ name: '' }],
};
// 使用节点类型获取对应的端口配置,如果没有则使用默认配置
return (portConfigs[this.node.type] || portConfigs.default)();
},
},
data() {
return {
isDragging: false,
moreMenuVisible: false,
menuPosition: { x: 0, y: 0 },
moreMenuNode: null,
};
},
methods: {
handleMouseDown(event) {
this.isDragging = false;
this.parent.selectedNode = this.node;
},
handleMouseOver() {
this.$nextTick(() => {
this.isDragging = true;
});
},
handleNodeClick(event) {
// 只有当不是拖动时才触发选择事件
if (!this.isDragging) {
this.$emit('select', this.node, event);
}
},
// 获取端口样式
getStyle(index, len) {
// 如果只有一个端口,居中显示
if (len == 1) {
return {
top: `50%`,
transform: 'translateY(-50%)',
};
}
// 计算多个端口的间距
const spacing = 100 / (len - 1);
return {
top: `${spacing * index}%`,
transform: 'translateY(-50%)',
};
},
// 添加右键菜单处理方法
handleContextMenu(event) {
// 阻止默认右键菜单
event.preventDefault();
// 触发父组件的showMenu事件
this.$emit('showContextMenu', event, this.node);
},
},
};
</script>
<style lang="scss" scoped>
.workflow-node {
position: absolute;
width: 250px;
min-height: 40px;
border-radius: 8px;
cursor: move;
user-select: none;
transition: all 0.3s ease;
background-color: #fff;
}
.node-content {
width: 100%;
}
.node-header {
padding: 8px 10px;
display: flex;
align-items: center;
}
.node-title {
font-weight: bold;
font-size: 12px;
color: #333;
}
.add-btn {
// display: none;
padding: 0;
width: 16px;
height: 16px;
background: #3b82f6;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: absolute;
text-align: center;
line-height: 100%;
font-size: 14px;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
background: #2563eb;
}
&::after {
content: attr(data-port-name);
position: absolute;
right: -60px;
font-size: 12px;
color: #666;
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover::after {
opacity: 1;
}
}
.left-btn {
left: -8px;
top: 50%;
transform: translateY(-50%);
display: none;
}
.right-btn {
right: -8px;
display: none;
z-index: 2;
}
.workflow-node:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
.left-btn,
.right-btn {
display: block;
}
.more-btn {
display: flex;
}
}
.node-selected {
border: 2px solid #3b82f6;
}
/* 添加更多按钮的样式 */
.more-btn {
padding: 2px 5px;
display: none;
position: absolute;
right: 5px;
top: -5px;
padding: 3px 5px;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
z-index: 1;
transform: rotate(90deg);
}
.more-icon {
font-size: 16px;
font-style: normal;
color: #666;
display: inline-block;
}
</style>

View File

@@ -0,0 +1,313 @@
<template>
<div>
<div v-if="visible"
class="more-menu"
:style="menuStyle">
<!-- 拷贝 -->
<div class="menu-item"
@click="copyNode">
<span>复制</span>
</div>
<!-- 新增子节点 -->
<div class="menu-item"
@click="handleShowAddChild"
v-if="canAddChild">
<span>新增子节点</span>
<i class="el-icon-arrow-right"></i>
</div>
<!-- 更换类型 -->
<div class="menu-item"
@click="handleShowChangeType">
<span>更换类型</span>
<i class="el-icon-arrow-right"></i>
</div>
<!-- 删除 -->
<div class="menu-item"
@click="deleteNode" v-if="node.type !== 'start'">
<span>删除</span>
</div>
</div>
<!-- 使用 NodeContextMenu 组件 - 更换类型 -->
<NodeContextMenu v-model:visible="showChangeType"
:position="changeTypeMenuPosition"
:node="node"
add-position="replace"
@add="handleChangeType" />
<!-- 使用 NodeContextMenu 组件 - 新增子节点 -->
<NodeContextMenu v-model:visible="showAddChild"
:position="addChildMenuPosition"
:node="node"
add-position="right"
@add="handleAddChild" />
</div>
</template>
<script>
import NodeContextMenu from './NodeContextMenu.vue'
import { getNodeConfig } from './nodes/nodeTypes.ts'
export default {
name: 'NodeMoreMenu',
inject: ['parent'],
components: {
NodeContextMenu
},
props: {
visible: {
type: Boolean,
default: false
},
position: {
type: Object,
default: () => ({ x: 0, y: 0 })
},
node: {
type: Object,
default: null
}
},
emits: ['update:visible'],
data () {
return {
showChangeType: false,
changeTypeMenuPosition: { x: 0, y: 0 },
showAddChild: false,
addChildMenuPosition: { x: 0, y: 0 }
}
},
computed: {
menuStyle () {
return {
left: `${this.position.x}px`,
top: `${this.position.y}px`
}
},
// 判断是否可以添加子节点
canAddChild () {
if (!this.node || !this.parent) return false
// 结束节点不能添加子节点
if (this.node.type === 'end') {
return false
}
// 获取当前节点已有的连接数量
const existingConnections = this.parent.connections?.filter((conn) => conn.sourceId === this.node.id) || []
// 如果是分支节点switch 或 question可以添加子节点
if (['switch', 'question'].includes(this.node.type)) {
return true
}
// 如果不是分支节点且已经有连接,则不能添加子节点
if (existingConnections.length > 0) {
return false
}
return true
}
},
methods: {
// 复制节点
copyNode () {
if (!this.node) return
// 创建节点的深拷贝
const nodeCopy = JSON.parse(JSON.stringify(this.node))
// 生成新的唯一ID
nodeCopy.id = `node_${Date.now()}`
// 设置新节点的位置(在原节点右下方20px处)
nodeCopy.x = this.node.x + 20
nodeCopy.y = this.node.y + 20
// 更新节点列表
this.parent.nodes = [...this.parent.nodes, nodeCopy]
// 关闭菜单
this.$emit('update:visible', false)
},
// 删除节点
deleteNode () {
if (!this.node) return
// 删除节点的所有端点
this.parent.jsPlumbInstance.removeAllEndpoints(this.node.id)
// 从节点列表中删除节点
this.parent.nodes= this.parent.nodes.filter(n => n.id !== this.node.id)
// 关闭菜单
this.$emit('update:visible', false)
},
// 更换节点类型
changeNodeType (newType) {
if (!this.node || !newType) return
// 获取新节点类型的配置
const nodeConfig = getNodeConfig(newType)
// 找到当前节点的索引
const nodeIndex = this.parent.nodes.findIndex(n => n.id === this.node.id)
if (nodeIndex === -1) return
// 保存原节点的位置和ID
const { x, y, id } = this.node
// 创建新节点保持原有的位置和ID
const newNode = {
...nodeConfig,
id,
x,
y,
}
// 更新节点列表
const newNodes = [...this.parent.nodes]
newNodes[nodeIndex] = newNode
this.parent.nodes = newNodes
// 关闭菜单
this.$emit('update:visible', false)
this.showChangeType = false
},
handleShowChangeType (event) {
// 计算子菜单位置,在父菜单右侧显示
const menuEl = this.$el.querySelector('.more-menu')
if (menuEl) {
const rect = menuEl.getBoundingClientRect()
// 检查右侧空间是否足够
const rightSpace = window.innerWidth - rect.right
const leftSpace = rect.left
// 如果右侧空间不足,且左侧空间足够,则显示在左侧
if (rightSpace < 200 && leftSpace > 200) {
this.changeTypeMenuPosition = {
x: rect.left - 5, // 左侧偏移5px
y: rect.top
}
} else {
this.changeTypeMenuPosition = {
x: rect.right + 5, // 右侧偏移5px
y: rect.top
}
}
}
this.showChangeType = true
// 阻止事件冒泡,防止触发外部点击事件
event?.stopPropagation()
},
handleChangeType (type) {
this.changeNodeType(type)
},
// 显示新增子节点菜单
handleShowAddChild (event) {
// 计算子菜单位置,在父菜单右侧显示
const menuEl = this.$el.querySelector('.more-menu')
if (menuEl) {
const rect = menuEl.getBoundingClientRect()
// 检查右侧空间是否足够
const rightSpace = window.innerWidth - rect.right
const leftSpace = rect.left
// 如果右侧空间不足,且左侧空间足够,则显示在左侧
if (rightSpace < 200 && leftSpace > 200) {
this.addChildMenuPosition = {
x: rect.left - 5, // 左侧偏移5px
y: rect.top
}
} else {
this.addChildMenuPosition = {
x: rect.right + 5, // 右侧偏移5px
y: rect.top
}
}
}
this.showAddChild = true
// 阻止事件冒泡,防止触发外部点击事件
event?.stopPropagation()
},
// 处理新增子节点
handleAddChild (type) {
if (!this.node || !this.parent || typeof this.parent.addNode !== 'function') return
// 模拟右键添加节点的流程
this.parent.contextMenuNode = this.node
this.parent.contextMenuAddPosition = 'right'
this.parent.contextMenuPortIndex = 0
// 调用父组件的 addNode 方法
this.parent.addNode(type)
// 关闭菜单
this.$emit('update:visible', false)
this.showAddChild = false
}
},
mounted () {
// 点击外部关闭菜单
const handleClickOutside = (e) => {
if (!this.$el.contains(e.target)) {
this.$emit('update:visible', false)
this.showChangeType = false
this.showAddChild = false
}
}
document.addEventListener('click', handleClickOutside)
// 保存引用以便在组件销毁时移除
this.handleClickOutside = handleClickOutside
},
beforeUnmount () {
// 移除事件监听器
document.removeEventListener('click', this.handleClickOutside)
}
}
</script>
<style scoped>
.more-menu {
opacity: 0.9;
position: fixed;
background: white;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 4px 0;
min-width: 160px;
z-index: 1000;
}
.menu-item {
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
font-size: 13px;
color: rgb(71 84 103 / 1);
transition: all 0.2s;
position: relative;
}
.menu-item:hover {
background-color: #f5f5f5;
}
.el-icon-arrow-right {
font-size: 12px;
color: #909399;
}
</style>

View File

@@ -0,0 +1,203 @@
<template>
<el-drawer v-model="drawerVisible" :title="node.name" :size="520" :show-close="false" direction="rtl" @close="$emit('close')">
<template #header>
<div class="px-5 py-4 w-full bg-white border-b border-gray-200">
<div class="flex justify-between items-center mb-3">
<div class="flex gap-2 items-center text-gray-800">
<svg-icon :size="24" :class="['node-icon', 'node-icon--' + node.type]" :name="`local-${node.type}`" />
<input class="text-base font-bold bg-transparent border-none outline-none w-30" v-model="node.name" />
<small class="text-xs text-gray-500">{{ node.id }}</small>
</div>
<!-- 关闭按钮 -->
<div class="p-2 rounded transition-colors cursor-pointer hover:bg-gray-100" @click="$emit('close')">
<el-icon><Close /></el-icon>
</div>
</div>
<!-- 描述区域 -->
<div>
<input
v-model="node.description"
class="w-full min-h-[30px] px-2 py-1 text-sm bg-gray-50 border border-gray-200 rounded outline-none transition-all hover:border-gray-300 hover:bg-white focus:border-blue-500 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.1)]"
placeholder="添加描述..."
/>
</div>
</div>
</template>
<div class="box-border flex flex-col h-full">
<div class="overflow-y-auto flex-1 pb-4">
<component :is="nodeConfig" :key="node.id" :node="node" />
</div>
<!-- 下一个节点显示区域 -->
<div class="px-5 py-4 bg-white border-t border-gray-200" v-if="node.type !== 'end'">
<div class="flex justify-between mb-2">
<div class="mb-2 text-sm font-bold text-gray-700">下一个节点</div>
</div>
<div class="p-4 bg-gray-50 rounded-lg">
<div class="relative" v-if="nextNodes.length">
<div class="flex items-center relative min-h-[100px]">
<!-- 当前节点 -->
<div class="flex items-center gap-2 px-3 py-2 bg-gray-100 border border-gray-200 rounded min-w-[120px] h-10 relative mr-[80px]">
<svg-icon :size="24" :class="['node-icon', 'node-icon--' + node.type]" :name="`local-${node.type}`" />
<span class="text-sm text-gray-700 truncate">{{ node.name }}</span>
<!-- 连接线 -->
<div class="absolute top-1/2 right-[-80px] w-[80px] h-[2px] bg-gray-200"></div>
</div>
<!-- 分支连线和节点 -->
<div class="flex relative flex-col flex-1 gap-8 pl-10 min-h-full">
<!-- 垂直连接线 -->
<div class="absolute left-0 top-5 h-[calc(100%-20px)] w-[2px] bg-gray-200"></div>
<div v-for="branch in nextNodes" :key="branch.node?.id" class="relative flex items-center gap-4 min-h-[40px]">
<!-- 水平连接线 -->
<div class="absolute top-1/2 left-[-40px] w-[20px] h-[2px] bg-gray-200"></div>
<!-- 分支节点 -->
<template v-if="branch.node">
<div class="flex items-center gap-2 px-3 py-2 bg-white border border-gray-200 rounded min-w-[120px] h-10 ml-[-80px]">
<svg-icon :size="24" :class="['node-icon', 'node-icon--' + branch.node.type]" :name="`local-${branch.node.type}`" />
<span class="text-sm text-gray-700 truncate">{{ branch.node.name }}</span>
</div>
<el-button type="primary" size="small" link @click.stop="jumpToNextNode(branch.node)" class="flex gap-1 items-center">
<el-icon><Right /></el-icon>
跳转到节点
</el-button>
</template>
<template v-else>
<el-button type="primary" plain size="small" class="w-[180px] h-9 justify-center gap-1 border-dashed">
<el-icon><Plus /></el-icon>
选择下一个节点
</el-button>
</template>
</div>
</div>
</div>
</div>
<div v-else class="py-5 text-center text-gray-500 bg-white rounded-lg border border-gray-200 border-dashed">暂无下一个节点</div>
</div>
</div>
</div>
</el-drawer>
</template>
<script>
import { Close, Right, Plus } from '@element-plus/icons-vue';
// 使用 import.meta.glob 自动导入所有面板组件
const modules = import.meta.glob('./panels/*.vue', { eager: true });
export default {
name: 'NodePanel',
components: {
Close,
Right,
Plus,
},
inject: ['parent'],
props: {
visible: {
type: Boolean,
default: false,
},
node: {
type: Object,
required: true,
},
},
data() {
return {
highlightedConnection: null,
};
},
computed: {
// 添加抽屉显示状态的计算属性
drawerVisible: {
get() {
return this.visible;
},
set(value) {
this.$emit('update:visible', value);
},
},
nodeConfig() {
// 从文件名中提取组件类型
// 检查节点类型是否存在,并构建面板组件名称
const panelName = this.node?.type ? `${this.node.type.charAt(0).toUpperCase()}${this.node.type.slice(1)}Panel` : '';
// 查找对应的面板组件
const panel = panelName ? Object.values(modules).find((module) => module.default.name === panelName) : {};
return panel?.default;
},
// 获取下一个节点的逻辑
nextNodes() {
if (!this.parent?.connections || !this.node) {
return [];
}
// 获取所有从当前节点出发的连接
const nextConnections = this.parent.connections.filter((conn) => conn.sourceId === this.node.id);
// 将连接转换为节点信息
return nextConnections.map((conn) => {
const targetNode = this.parent.nodes.find((node) => node.id === conn.targetId);
return {
node: targetNode,
condition: conn.condition || '默认分支',
};
});
},
},
methods: {
jumpToNextNode(node) {
if (!node) return;
// 延迟执行以确保 DOM 已更新
this.$nextTick(() => {
// 获取工作流容器元素
const workflowContainer = document.querySelector('.workflow-container');
// 获取目标节点元素
const nextNodeElement = workflowContainer?.querySelector(`#${node.id}`);
if (nextNodeElement && workflowContainer) {
// 计算目标节点相对于容器的位置
const containerRect = workflowContainer.getBoundingClientRect();
const nodeRect = nextNodeElement.getBoundingClientRect();
// 计算滚动位置,使节点居中显示
const scrollLeft = nodeRect.left - containerRect.left - (containerRect.width - nodeRect.width) / 2;
const scrollTop = nodeRect.top - containerRect.top - (containerRect.height - nodeRect.height) / 2;
// 平滑滚动到目标位置
workflowContainer.scrollTo({
left: workflowContainer.scrollLeft + scrollLeft,
top: workflowContainer.scrollTop + scrollTop,
behavior: 'smooth',
});
}
// 选中节点
this.parent.selectNode(node);
});
},
},
};
</script>
<style scoped>
/* 保留节点图标样式,这部分可能需要单独处理 */
:deep(.highlight-connection) {
stroke: var(--el-color-primary) !important;
stroke-width: 2px !important;
animation: connectionPulse 2s infinite;
}
@keyframes connectionPulse {
0% {
stroke-opacity: 1;
}
50% {
stroke-opacity: 0.5;
}
100% {
stroke-opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,336 @@
<template>
<div class="workflow-designer">
<!-- 添加未发布遮罩组件 -->
<UnpublishedMask :visible="!form.enabled" />
<!-- 执行面板 -->
<div class="execution-panel"
v-if="form.enabled">
<div class="panel-header">
<h3>流程运行{{ id }}</h3>
</div>
<div class="panel-content">
<!-- 参数输入区域 -->
<div class="left-panel">
<div class="variable-inputs">
<div v-for="(param, index) in startNodeParams"
:key="index"
class="input-item">
<div class="input-label"
:class="{ 'required': param.required }">
{{ param.name }}
</div>
<div class="input-value">
<input v-if="param.inputType === 'input'"
v-model="param.value"
class="param-input"
:disabled="isRunning"
:class="{ 'error': showError && param.required && !param.value }"
:placeholder="'请输入' + param.name" />
<input v-else-if="param.inputType === 'number'"
type="number"
v-model.number="param.value"
class="param-input"
:disabled="isRunning"
:class="{ 'error': showError && param.required && !param.value }"
:placeholder="'请输入' + param.name" />
<textarea v-else-if="param.inputType === 'textarea'"
v-model="param.value"
class="param-textarea"
:disabled="isRunning"
:class="{ 'error': showError && param.required && !param.value }"
:placeholder="'请输入' + param.name"
rows="3"></textarea>
<select v-else-if="param.inputType === 'select'"
v-model="param.value"
class="param-select"
:disabled="isRunning"
:class="{ 'error': showError && param.required && !param.value }">
<option value="">请选择{{ param.name }}</option>
<option v-for="option in param.options"
:key="option.value"
:value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div>
<el-button type="primary"
class="run-btn"
:disabled="isRunning"
@click="handleParamRun">
{{ isRunning ? '运行中...' : '运行' }}
</el-button>
</div>
<!-- 执行状态和结果区域 -->
<div class="execution-detail"
v-if="executionNodes.length">
<div class="detail-card">
<div class="detail-row">
<div class="detail-item">
<div class="label">状态</div>
<div class="value"
:class="executionStatus.class">
{{ executionStatus.text }}
</div>
</div>
<div class="detail-item">
<div class="label">运行时间</div>
<div class="value">{{ formatTotalTime(executionTime) }}</div>
</div>
<div class="detail-item">
<div class="label"> TOKEN </div>
<div class="value">{{ totalTokens }} Tokens</div>
</div>
</div>
</div>
</div>
</div>
<div class="right-panel">
<!-- 执行进度和结果区域 -->
<node-list :nodes="executionNodes"
@end="handleEnd"
v-if="executionNodes.length" />
<!-- 最终执行结果 -->
<div class="execution-result"
v-if="executionResult">
<h4>执行结果</h4>
<pre>{{ JSON.stringify(executionResult, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Loading, Check, CircleClose, ArrowRight } from '@element-plus/icons-vue'
import { getObj } from '/@/api/knowledge/aiFlow';
import NodeList from './components/NodeList.vue'
import NodeCommon from './mixins/Node.ts'
import UnpublishedMask from './components/UnpublishedMask.vue'
export default {
name: 'WorkflowRun',
mixins: [NodeCommon],
components: {
Loading,
Check,
CircleClose,
ArrowRight,
NodeList,
UnpublishedMask
},
provide () {
return {
parent: this,
nodes: this.nodes,
}
},
data () {
return {
form: { enabled: true },
executionNodes: [],
executionResult: null,
executionTime: 0,
totalTokens: 0,
startNodeParams: [], // 添加开始节点参数
showError: false,
isRunning: false // 添加运行状态控制
}
},
computed: {
executionStatus () {
const lastNode = this.executionNodes[this.executionNodes.length - 1]
if (!lastNode) return { text: '等待中', class: 'status-pending' }
const statusMap = {
'running': { text: '运行中', class: 'status-running' },
'success': { text: '成功', class: 'status-success' },
'error': { text: '失败', class: 'status-error' },
'skipped': { text: '已跳过', class: 'status-skipped' }
}
return statusMap[lastNode.status] || { text: '等待中', class: 'status-pending' }
},
},
created () {
this.loadFromStorage()
},
unmounted() {
this.resetConversation();
},
methods: {
// 修改 loadFromStorage 方法
async loadFromStorage () {
try {
const res = await getObj(this.id)
this.form = res.data.data;
const { dsl = '{}' } = this.form
const data = JSON.parse(dsl)
this.nodes = data.nodes || []
this.connections = data.connections || []
this.env = data.env || []
this.handleRunClick()
} catch (error) {
console.error('加载工作流失败:', error)
}
},
formatTotalTime (time) {
if (!time) return '0ms'
return `${Number(time).toFixed(3)}ms`
},
handleParamRun () {
const hasError = this.startNodeParams.some(param => param.required && !param.value)
this.showError = hasError
if (hasError) {
return
}
this.runWorkflow(this.startNodeParams)
this.showError = false
},
handleEnd (status) {
this.isRunning = false
}
}
}
</script>
<style lang="scss" scoped>
@use './styles/flow.scss';
.workflow-designer {
width: 100%;
height: 100vh;
background: #f8f9fc;
position: relative;
overflow: hidden;
display: flex;
}
.execution-panel {
width: 100%;
height: 100%;
background: white;
border-left: 1px solid #e6e6e6;
display: flex;
color: #333;
flex-direction: column;
}
.panel-header {
padding: 15px;
border-bottom: 1px solid #e6e6e6;
background: #f8f9fc;
h3 {
margin: 0;
font-size: 16px;
color: #303133;
}
}
.panel-content {
display: flex;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.left-panel {
padding: 15px;
width: 350px;
height: 100%;
background: #fff;
border-right: 1px solid #dcdfe6;
}
.right-panel {
flex: 1;
height: 100%;
overflow-y: auto;
box-sizing: border-box;
padding: 20px;
}
.execution-detail {
margin-top: 20px;
}
.detail-card {
background: #f0f9eb;
border-radius: 8px;
border: 1px solid #e1f3d8;
box-sizing: border-box;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-item {
flex: 1;
text-align: center;
padding: 8px 0;
&:not(:last-child) {
border-right: 1px solid rgba(225, 243, 216, 0.8);
}
.label {
color: #606266;
font-size: 12px;
margin-bottom: 4px;
}
.value {
color: #67c23a;
font-size: 14px;
font-weight: 500;
&.status-success {
color: #67c23a;
}
&.status-error {
color: #f56c6c;
}
&.status-running {
color: #409eff;
}
&.status-pending {
color: #909399;
}
}
}
.run-btn {
width: 100%;
}
.execution-result {
margin-top: 20px;
h4 {
margin-bottom: 10px;
color: #303133;
}
pre {
background: #f5f7fa;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
font-family: monospace;
font-size: 13px;
line-height: 1.5;
}
}
</style>

View File

@@ -0,0 +1,271 @@
<template>
<div class="workflow-toolbar">
<div class="toolbar-box">
<!-- 主工具栏分组 -->
<div class="toolbar-group">
<el-tooltip content="检查工作流" placement="bottom">
<el-button class="toolbar-btn" @click="handleCheck">
<el-icon>
<List />
</el-icon>
<span v-if="validation && !validation.isValid" class="badge">
{{ validation.errors.length }}
</span>
</el-button>
</el-tooltip>
<el-tooltip content="环境设置" placement="bottom">
<el-button class="toolbar-btn" @click="handleEnvSettings">
<el-icon>
<Setting />
</el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="数据结构" placement="bottom">
<el-button class="toolbar-btn" @click="handlePreview">
<el-icon>
<Document />
</el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="API 信息" placement="bottom">
<el-button class="toolbar-btn" @click="showApi = true">
<el-icon>
<Connection />
</el-icon>
</el-button>
</el-tooltip>
<template v-if="parent.isChatFlow">
<el-tooltip content="开场白设置" placement="bottom">
<el-button class="toolbar-btn" @click="showGreetingEditor = true">
<el-icon>
<ChatDotRound />
</el-icon>
</el-button>
</el-tooltip>
</template>
</div>
<!-- 运行按钮 -->
<el-button class="run-btn" :disabled="!canRun" icon="video-play" @click="handleCommand('run')"> 运行 </el-button>
<!-- 发布按钮 -->
<el-dropdown @command="handleCommand">
<el-button class="publish-btn" type="primary" icon="circle-check" @click="handleSave"> 保存 </el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="share" v-if="parent.isChatFlow">
<el-icon><Share /></el-icon>
分享流程
</el-dropdown-item>
<el-dropdown-item command="runs">
<el-icon><Upload /></el-icon>
保存并发布
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 关闭按钮 -->
<el-tooltip content="关闭" placement="bottom">
<el-button class="close-btn" @click="handleClose">
<el-icon>
<CloseBold />
</el-icon>
</el-button>
</el-tooltip>
<!-- 面板组件 -->
<json-preview-panel v-model="showPreview" />
<checkListPanel v-show="showCheck" v-model:validation="validation" @close="showCheck = false" />
<envSettingsPanel v-model="showEnv" @close="closeEnvPanel" />
<api-panel v-model="showApi" :flow-id="parent.id" />
<!-- 添加开场白编辑器组件 -->
<greeting-editor v-model="showGreetingEditor" />
</div>
</div>
</template>
<script>
import { putObj } from '/@/api/knowledge/aiFlow';
import { List, Setting, Upload, Document, Share, ChatDotRound, CloseBold, Connection } from '@element-plus/icons-vue';
import JsonPreviewPanel from './JsonPreviewPanel.vue';
import CheckListPanel from './CheckListPanel.vue';
import EnvSettingsPanel from './EnvSettingsPanel.vue';
import ApiPanel from './ApiPanel.vue';
import { useRouter } from 'vue-router';
import GreetingEditor from './GreetingEditor.vue';
export default {
name: 'Toolbar',
inject: ['parent'],
components: {
CheckListPanel,
EnvSettingsPanel,
JsonPreviewPanel,
GreetingEditor,
ApiPanel,
Upload,
Share,
List,
Setting,
Document,
Connection,
ChatDotRound,
CloseBold,
},
setup() {
const router = useRouter();
const update = async (data) => {
return putObj(data);
};
return { router, update };
},
data() {
return {
showEmbed: false,
showGreetingEditor: false,
showApi: false,
validation: {
errors: [],
isValid: false,
},
showEnv: false,
showPreview: false,
showCheck: false,
};
},
computed: {
canRun() {
return this.validation && this.validation.isValid;
},
},
methods: {
handlePreview() {
this.showPreview = !this.showPreview;
},
async handleSave(enabled) {
this.showPreview = false;
const { nodes, connections } = this.parent;
// 构建完整的工作流状态
const state = {
nodes,
connections,
env: this.parent.env,
greeting: this.parent.dsl.greetingText,
questions: this.parent.dsl.questions,
};
try {
// 构建工作流DSL数据
const workflowData = {
id: this.parent.id,
dsl: JSON.stringify(state),
enabled: enabled,
};
// 调用API保存工作流
await this.update(workflowData);
this.$message.success('保存成功');
} catch (error) {
this.$message.error('保存失败:' + error.message);
}
},
handleCheck() {
this.showCheck = !this.showCheck;
},
handleRun() {
this.$emit('run');
},
handleEnvSettings() {
this.showEnv = true;
},
handleWorkflowRun() {
this.handleSave('1');
},
// 关闭环境设置面板
closeEnvPanel() {
this.showEnv = false;
},
// 添加下拉菜单命令处理方法
handleCommand(command) {
switch (command) {
case 'save':
this.handleSave();
break;
case 'share':
this.showEmbed = true;
break;
case 'run':
this.handleRun();
break;
case 'runs':
this.handleWorkflowRun();
break;
}
},
// 添加关闭方法
handleClose() {
this.$router.go(-1); // 返回上一页
},
},
emits: ['run'],
};
</script>
<style lang="scss" scoped>
.workflow-toolbar {
padding: 5px 0;
position: absolute;
right: 0;
background: #fff;
z-index: 100;
}
.toolbar-box {
display: flex;
gap: 12px;
align-items: center;
padding: 0 20px;
}
.toolbar-group {
display: flex;
gap: 4px;
padding: 4px;
.toolbar-btn {
margin: 0;
padding: 8px 12px;
height: auto;
}
}
.publish-btn {
margin-left: 4px;
}
.run-btn {
margin-top: 0;
margin-left: 8px;
}
.close-btn {
margin-left: 8px;
}
.badge {
padding: 0 6px;
background: #ff4d4f;
border-radius: 10px;
color: white;
font-size: 12px;
line-height: 16px;
}
</style>

View File

@@ -0,0 +1,321 @@
<template>
<div class="zoom-control">
<el-button-group>
<el-button @click="handleZoomIn" icon="zoom-in" size="small"></el-button>
<el-button @click="handleZoomOut" icon="zoom-out" size="small"></el-button>
<el-button @click="handleResetZoom" icon="refresh" size="small"></el-button>
<el-button @click="handleArrangeNodes" icon="sort" size="small" title="一键整理"></el-button>
</el-button-group>
<span class="zoom-text">{{ Math.round(zoom * 100) }}%</span>
</div>
</template>
<script>
export default {
name: 'ZoomControl',
inject: ['parent'], // 注入父组件引用
props: {
zoom: {
type: Number,
default: 1,
},
minZoom: {
type: Number,
default: 0.1,
},
maxZoom: {
type: Number,
default: 3,
},
zoomStep: {
type: Number,
default: 0.1,
},
position: {
type: Object,
default: () => ({ x: 0, y: 0 }),
},
},
emits: ['update:zoom', 'updatePosition'],
methods: {
// 放大
handleZoomIn() {
const newZoom = Math.min(this.maxZoom, this.zoom + this.zoomStep);
this.$emit('update:zoom', newZoom);
},
// 缩小
handleZoomOut() {
const newZoom = Math.max(this.minZoom, this.zoom - this.zoomStep);
this.$emit('update:zoom', newZoom);
},
// 重置缩放
handleResetZoom() {
this.$emit('update:zoom', 1);
this.$emit('updatePosition', { x: 0, y: 0 });
},
/**
* 一键整理节点
* 按照工作流程图从左到右、从上到下的布局方式排列节点
*/
handleArrangeNodes() {
if (!this.parent?.nodes?.length) return;
const HORIZONTAL_GAP = 300; // 节点之间的水平间距
const VERTICAL_GAP = 250; // 节点之间的垂直间距
const START_X = 200; // 起始X坐标
const START_Y = 300; // 起始Y坐标
const visited = new Set(); // 记录已访问的节点
const levels = new Map(); // 记录每个节点的层级
const positions = new Map(); // 记录节点位置
// 1. 找到所有起始节点入度为0的节点
const startNodes = this.parent.nodes.filter((node) => !this.parent.connections.some((conn) => conn.targetId === node.id));
// 2. 使用 BFS 计算每个节点的层级
const queue = startNodes.map((node) => ({ node, level: 0 }));
while (queue.length > 0) {
const { node, level } = queue.shift();
if (visited.has(node.id)) continue;
visited.add(node.id);
if (!levels.has(level)) {
levels.set(level, []);
}
levels.get(level).push(node);
// 获取当前节点的所有后继节点
const nextNodes = this.parent.connections
.filter((conn) => conn.sourceId === node.id)
.map((conn) => this.parent.nodes.find((n) => n.id === conn.targetId))
.filter(Boolean);
// 将后继节点加入队列,层级+1
nextNodes.forEach((nextNode) => {
if (!visited.has(nextNode.id)) {
queue.push({ node: nextNode, level: level + 1 });
}
});
}
// 3. 计算每个节点的新位置
let maxNodesInLevel = 0;
levels.forEach((nodes) => {
maxNodesInLevel = Math.max(maxNodesInLevel, nodes.length);
});
// 4. 为每个层级的节点分配位置
levels.forEach((nodes, level) => {
const levelWidth = HORIZONTAL_GAP;
const levelStartY = START_Y + (maxNodesInLevel * VERTICAL_GAP - nodes.length * VERTICAL_GAP) / 2;
nodes.forEach((node, index) => {
const x = START_X + level * levelWidth;
const y = levelStartY + index * VERTICAL_GAP;
positions.set(node.id, { x, y });
});
});
// 5. 使用动画更新节点位置
const duration = 500; // 动画持续时间(毫秒)
const startTime = performance.now();
const startPositions = new Map(this.parent.nodes.map((node) => [node.id, { x: node.x, y: node.y }]));
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// 使用缓动函数使动画更自然
const easeProgress = this.easeInOutCubic(progress);
// 更新所有节点的位置
this.parent.nodes.forEach((node) => {
if (positions.has(node.id)) {
const startPos = startPositions.get(node.id);
const targetPos = positions.get(node.id);
node.x = startPos.x + (targetPos.x - startPos.x) * easeProgress;
node.y = startPos.y + (targetPos.y - startPos.y) * easeProgress;
}
});
// 继续动画或结束
if (progress < 1) {
requestAnimationFrame(animate);
} else {
// 动画结束后重新计算连线
this.parent.jsPlumbInstance?.repaintEverything();
// 等待连线重绘完成后再居中显示
setTimeout(() => {
this.calculateFitZoom();
}, 100);
}
};
requestAnimationFrame(animate);
},
/**
* 缓动函数
* @param {number} t - 进度值 (0-1)
* @returns {number} 缓动后的进度值
*/
easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
},
/**
* 计算合适的缩放比例,使所有节点都在可视区域内
*/
calculateFitZoom() {
if (!this.parent?.nodes?.length) return 1;
// 获取工作区容器
const container = document.querySelector('.workflow-container');
if (!container) return 1;
// 计算节点的边界
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
this.parent.nodes.forEach((node) => {
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x + 200); // 假设节点宽度为 200
maxY = Math.max(maxY, node.y + 100); // 假设节点高度为 100
});
// 添加边距
const PADDING = 100; // 增加边距,让画面更加宽松
minX -= PADDING;
minY -= PADDING;
maxX += PADDING;
maxY += PADDING;
// 计算内容和容器的宽高比
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
// 计算合适的缩放比例,确保完整显示
const scaleX = containerWidth / contentWidth;
const scaleY = containerHeight / contentHeight;
const scale = Math.min(scaleX, scaleY, 1); // 不超过 1 倍
// 计算居中位置,考虑缩放因素
const centerX = (maxX + minX) / 2;
const centerY = (maxY + minY) / 2;
// 计算平移位置,使内容在容器中居中
const translateX = containerWidth / 2 - centerX * scale;
const translateY = containerHeight / 2 - centerY * scale;
// 使用动画平滑过渡到新的位置和缩放
const duration = 500;
const startTime = performance.now();
const startZoom = this.zoom;
const startPos = { ...this.position };
const targetPos = {
x: translateX / scale,
y: translateY / scale,
};
const animateView = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = this.easeInOutCubic(progress);
// 更新缩放和位置
const currentZoom = startZoom + (scale - startZoom) * easeProgress;
const currentX = startPos.x + (targetPos.x - startPos.x) * easeProgress;
const currentY = startPos.y + (targetPos.y - startPos.y) * easeProgress;
this.$emit('update:zoom', currentZoom);
this.$emit('updatePosition', { x: currentX, y: currentY });
if (progress < 1) {
requestAnimationFrame(animateView);
}
};
requestAnimationFrame(animateView);
return scale;
},
},
};
</script>
<style lang="scss" scoped>
.zoom-control {
position: absolute;
left: 20px;
bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
z-index: 100;
padding: 4px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.el-button-group {
.el-button {
padding: 6px 8px;
border: 1px solid #e4e7ed;
background: transparent;
&:hover {
background-color: #f5f7fa;
color: #409eff;
}
&:first-child {
border-radius: 4px 0 0 4px;
}
&:last-child {
border-radius: 0 4px 4px 0;
}
// 移除按钮文字
span:last-child {
display: none;
}
// 添加按钮提示效果
&[title] {
position: relative;
&:hover::after {
content: attr(title);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 4px 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 12px;
border-radius: 4px;
white-space: nowrap;
margin-bottom: 6px;
}
}
}
}
.zoom-text {
color: #606266;
font-size: 12px;
min-width: 42px;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<!-- 对话详情抽屉 -->
<el-drawer v-model="visible"
title="对话详情"
size="500"
:before-close="handleClose"
class="chat-detail-drawer">
<div v-loading="loading">
<ChatMessage :messages="messages"
v-if="messages.length > 0"
ref="messageContainer" />
<el-empty v-else
description="暂无对话记录">
</el-empty>
</div>
</el-drawer>
</template>
<script>
import { marked } from 'marked'
import { Cpu } from '@element-plus/icons-vue'
import ChatMessage from './ChatMessage.vue'
export default {
name: 'ChatDetail',
components: {
Cpu,
ChatMessage
},
props: {
// 对话ID
conversationId: {
type: [String, Number],
default: ''
},
// 是否显示弹窗
modelValue: {
type: Boolean,
default: false
}
},
data () {
return {
loading: false,
messages: []
}
},
computed: {
visible: {
get () {
return this.modelValue
},
set (val) {
this.$emit('update:modelValue', val)
}
}
},
watch: {
conversationId: {
handler (val) {
if (val) {
this.loadMessages()
}
},
immediate: true
}
},
methods: {
// 加载消息列表
async loadMessages () {
if (!this.conversationId) return
this.loading = true
try {
const res = null
this.messages = res.data.data.records || []
} catch (error) {
console.error('加载消息失败:', error)
this.$message.error('加载消息失败')
} finally {
this.loading = false
}
},
// 解析 Markdown
parseMarkdown (text) {
if (!text) return ''
return marked(text)
},
// 关闭弹窗
handleClose () {
this.visible = false
},
/**
* 处理消息编辑事件
* @param {Object} data - 包含索引和更新后消息的对象
*/
handleMessageEdited(data) {
// 更新消息数组中的消息
if (data && data.index >= 0 && data.index < this.messages.length) {
this.messages[data.index] = data.message;
// 发送更新事件给父组件
this.$emit('message-updated', this.messages);
}
},
/**
* 处理消息删除事件
* @param {Number} index - 要删除的消息索引
*/
handleMessageDeleted(index) {
// 从消息数组中删除消息
if (index >= 0 && index < this.messages.length) {
this.messages.splice(index, 1);
// 发送更新事件给父组件
this.$emit('message-updated', this.messages);
}
}
}
}
</script>
<style lang="scss">
.chat-detail-drawer {
.el-drawer__body{
padding: 0;
}
}
</style>

View File

@@ -0,0 +1,570 @@
<template>
<div class="messages">
<div v-for="(msg, index) in messages"
:key="index"
class="message"
:class="[msg.role]"
@contextmenu.prevent="showContextMenu($event, msg, index)">
<div class="avatar">
<img :src="msg.role === 'user' ? currentUserAvatar : botAvatar"
alt="avatar" />
</div>
<div style="width: 100%;">
<!-- 消息时间显示 - 移到content外面 -->
<div class="time">{{ getMessageTime(msg) }}</div>
<div class="content">
<!-- 思考内容区域添加可折叠功能默认展开 -->
<div class="collapsible_wrapper"
v-if="msg.reasoning_content">
<div class="collapsible_tag"
@click="toggleContent(index, 'reasoning')">
<el-icon class="collapsible-icon"
:class="{ 'is-rotate': contentVisible[index]?.reasoning !== false }">
<ArrowDown />
</el-icon>
<span>深度思考</span>
</div>
<div class="collapsible_content"
v-show="contentVisible[index]?.reasoning !== false">
{{ msg.reasoning_content }}
</div>
</div>
<template v-if="msg.content">
<div v-html="parseMarkdown(msg.content)"></div>
<div class="questions"
v-if="msg.questions">
<el-tag v-for="(question, qIndex) in msg.questions"
:key="qIndex"
type="primary"
@click="handleQuestionClick(question.text)">
{{ question.text }}
</el-tag>
</div>
</template>
<div v-else-if="msg.nodes"
class="collapsible_wrapper">
<div class="collapsible_tag"
@click="toggleContent(index, 'nodes')">
<el-icon class="collapsible-icon"
:class="{ 'is-rotate': contentVisible[index]?.nodes !== false }">
<ArrowDown />
</el-icon>
<span>执行步骤</span>
</div>
<div class="collapsible_content"
v-show="contentVisible[index]?.nodes !== false">
<node-list :nodes="msg.nodes" />
</div>
<div v-if="msg.result"
v-html="parseMarkdown(msg.result)">
</div>
</div>
<template v-if="loading && messages.indexOf(msg) === messages.length - 1">
<div class="typing-indicator">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</template>
</div>
</div>
</div>
<!-- 右键菜单 -->
<el-dialog v-model="editDialogVisible"
title="编辑消息"
width="50%"
:before-close="handleEditDialogClose">
<el-input v-model="editingContent"
type="textarea"
:rows="10"
placeholder="请输入消息内容" />
<template #footer>
<span class="dialog-footer">
<el-button @click="handleEditDialogClose">取消</el-button>
<el-button type="primary"
@click="saveEditedMessage">确认</el-button>
</span>
</template>
</el-dialog>
<!-- 自定义右键菜单 -->
<div v-show="contextMenuVisible"
class="context-menu"
:style="{ top: contextMenuTop + 'px', left: contextMenuLeft + 'px' }">
<div class="context-menu-item"
@click="copyMessage">
<el-icon>
<Document />
</el-icon>
<span>复制</span>
</div>
<div class="context-menu-item"
@click="editMessage">
<el-icon>
<Edit />
</el-icon>
<span>编辑</span>
</div>
<div class="context-menu-item"
@click="deleteMessage">
<el-icon>
<Delete />
</el-icon>
<span>删除</span>
</div>
<div class="context-menu-item"
@click="speakMessage">
<el-icon>
<Microphone />
</el-icon>
<span>朗读</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { marked } from 'marked'
import hljs from 'highlight.js/lib/core'
// 按需导入常用的语言
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import xml from 'highlight.js/lib/languages/xml'
import css from 'highlight.js/lib/languages/css'
import scss from 'highlight.js/lib/languages/scss'
import json from 'highlight.js/lib/languages/json'
import bash from 'highlight.js/lib/languages/bash'
import markdown from 'highlight.js/lib/languages/markdown'
// 导入暗色主题样式
import 'highlight.js/styles/atom-one-dark.css'
// 导入Element Plus图标
import { ArrowDown, Document, Edit, Delete, Microphone } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import NodeList from '/@/views/knowledge/aiFlow/components/NodeList.vue'
import {dateTimeStr} from "/@/utils/formatTime";
// 定义组件接收的props
const props = defineProps({
loading: {
type: Boolean,
default: false
},
messages: {
type: Array,
required: true
},
currentUserAvatar: {
type: String,
default: '/img/chat/icon.png'
},
botAvatar: {
type: String,
default: '/img/chat/chatgpt.png'
}
})
// 定义组件触发的事件
const emit = defineEmits(['item-click', 'change'])
// 注册语言
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('python', python)
hljs.registerLanguage('java', java)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('css', css)
hljs.registerLanguage('scss', scss)
hljs.registerLanguage('json', json)
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('markdown', markdown)
// 配置marked的代码高亮选项
onMounted(() => {
marked.setOptions({
highlight: function (code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value
} catch (e) {
console.error(e)
return code
}
}
return hljs.highlightAuto(code).value
},
breaks: true, // 支持换行符
gfm: true, // 启用GitHub风格Markdown
sanitize: false, // 允许HTML标签以支持代码高亮
langPrefix: 'hljs language-', // 添加代码块的class前缀
})
})
// 使用reactive创建响应式对象用于跟踪内容的显示状态
const contentVisible = reactive({})
// 右键菜单相关状态
const contextMenuVisible = ref(false)
const contextMenuTop = ref(0)
const contextMenuLeft = ref(0)
const currentMessage = ref(null)
const currentMessageIndex = ref(-1)
// 编辑对话框相关状态
const editDialogVisible = ref(false)
const editingContent = ref('')
/**
* 切换内容的显示/隐藏状态
* @param {number} index - 消息索引
* @param {string} type - 内容类型 ('reasoning' 或 'nodes')
*/
const toggleContent = (index, type) => {
if (!contentVisible[index]) {
contentVisible[index] = {}
}
contentVisible[index][type] = contentVisible[index][type] === false ? true : false
}
/**
* 获取消息的时间
* @param {Object} msg - 消息对象
* @returns {string} - 格式化后的时间字符串
*/
const getMessageTime = (msg) => {
// 如果消息对象中已有时间属性,则直接使用
if (msg.time) {
return parseDate(msg.time,dateTimeStr)
}
// 否则生成当前时间并赋值给消息对象
const currentTime = parseDate(new Date(),dateTimeStr)
// 为消息对象添加时间属性
msg.time = new Date().toISOString()
return currentTime
}
/**
* 显示右键菜单
* @param {Event} event - 鼠标事件对象
* @param {Object} msg - 消息对象
* @param {number} index - 消息索引
*/
const showContextMenu = (event, msg, index) => {
// 阻止默认右键菜单
event.preventDefault()
// 设置菜单位置
contextMenuTop.value = event.clientY
contextMenuLeft.value = event.clientX
// 保存当前消息和索引
currentMessage.value = msg
currentMessageIndex.value = index
// 显示菜单
contextMenuVisible.value = true
}
/**
* 隐藏右键菜单
*/
const hideContextMenu = () => {
contextMenuVisible.value = false
}
/**
* 复制消息内容到剪贴板
*/
const copyMessage = () => {
if (!currentMessage.value) return
// 获取要复制的文本内容
const textToCopy = currentMessage.value.content ||
currentMessage.value.reasoning_content ||
(currentMessage.value.result || '')
// 使用Clipboard API复制文本
navigator.clipboard.writeText(textToCopy)
.then(() => {
ElMessage.success('复制成功')
})
.catch(err => {
ElMessage.error('复制失败: ' + err)
})
// 隐藏菜单
hideContextMenu()
}
/**
* 打开编辑对话框
*/
const editMessage = () => {
if (!currentMessage.value) return
// 设置编辑内容
editingContent.value = currentMessage.value.content ||
currentMessage.value.reasoning_content ||
(currentMessage.value.result || '')
// 显示编辑对话框
editDialogVisible.value = true
// 隐藏菜单
hideContextMenu()
}
/**
* 关闭编辑对话框
*/
const handleEditDialogClose = () => {
editDialogVisible.value = false
editingContent.value = ''
}
/**
* 保存编辑后的消息
*/
const saveEditedMessage = () => {
if (currentMessageIndex.value === -1 || !currentMessage.value) return
// 更新时间
currentMessage.value.time = dayjs().toISOString()
// 更新消息内容
currentMessage.value.content = editingContent.value
// 发送编辑消息事件
emit('change')
// 关闭对话框
handleEditDialogClose()
ElMessage.success('消息已更新')
}
/**
* 删除消息
*/
const deleteMessage = () => {
if (currentMessageIndex.value === -1) return
// 从消息数组中删除当前消息
props.messages.splice(currentMessageIndex.value, 1)
// 发送删除消息事件
emit('change')
// 隐藏菜单
hideContextMenu()
ElMessage.success('消息已删除')
}
/**
* 使用浏览器API朗读消息
*/
const speakMessage = () => {
if (!currentMessage.value) return
// 获取要朗读的文本
const textToSpeak = currentMessage.value.content ||
currentMessage.value.reasoning_content ||
(currentMessage.value.result || '')
// 检查浏览器是否支持语音合成
if ('speechSynthesis' in window) {
// 创建语音合成实例
const utterance = new SpeechSynthesisUtterance(textToSpeak)
// 设置语音属性
utterance.lang = 'zh-CN' // 设置语言为中文
utterance.rate = 1.0 // 设置语速
utterance.pitch = 1.0 // 设置音调
// 开始朗读
window.speechSynthesis.speak(utterance)
ElMessage.success('正在朗读消息')
} else {
ElMessage.error('您的浏览器不支持语音合成')
}
// 隐藏菜单
hideContextMenu()
}
/**
* 处理问题点击事件
* @param {string} text - 问题文本
*/
const handleQuestionClick = (text) => {
emit('item-click', text)
}
/**
* 解析Markdown内容并支持代码高亮
* @param {string} content - 需要解析的Markdown内容
* @returns {string} - 解析后的HTML字符串
*/
const parseMarkdown = (content) => {
if (!content) return ''
return marked(content)
}
// 点击页面其他区域时隐藏右键菜单
const handleDocumentClick = () => {
hideContextMenu()
}
// 组件挂载时添加全局点击事件监听
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
})
// 组件卸载前移除全局点击事件监听
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick)
})
</script>
<style scoped lang="scss">
// 可折叠内容区域通用样式
.collapsible_wrapper {
overflow: hidden;
}
.collapsible_tag {
display: inline-flex;
align-items: center;
padding: 0px 8px;
background-color: #f0f0f0;
border-radius: 4px;
cursor: pointer;
user-select: none;
transition: all 0.2s;
border: 1px solid #e0e0e0;
margin-bottom: 6px;
&:hover {
background-color: #e8e8e8;
border-color: #d0d0d0;
}
.collapsible-icon {
margin-right: 6px;
transition: transform 0.3s;
font-size: 12px;
color: #909399;
&.is-rotate {
transform: rotate(180deg);
}
}
span {
font-size: 12px;
color: #606266;
font-weight: 500;
}
}
.collapsible_content {
position: relative;
margin: 5px 0;
padding: 0px 12px;
box-sizing: border-box;
font-size: 13px;
&:before {
position: absolute;
top: 0;
left: 0;
content: ' ';
height: 100%;
width: 2px;
background-color: #e5e5e5;
}
}
// 右键菜单样式
.context-menu {
position: fixed;
z-index: 9999;
background: white;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 5px 0;
min-width: 120px;
}
.context-menu-item {
padding: 8px 16px;
cursor: pointer;
display: flex;
align-items: center;
transition: background-color 0.3s;
&:hover {
background-color: #f5f7fa;
}
.el-icon {
margin-right: 8px;
font-size: 16px;
}
span {
font-size: 14px;
}
}
:deep(pre) {
background-color: #282c34; // Atom One Dark 背景色
border-radius: 6px;
padding: 16px;
overflow: auto;
font-size: 14px;
line-height: 1.45;
margin-bottom: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
:deep(code) {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: #3a404b; // Atom One Dark 次要背景色
color: #abb2bf; // Atom One Dark 文本颜色
border-radius: 3px;
}
:deep(pre code) {
padding: 0;
margin: 0;
font-size: 100%;
word-break: normal;
white-space: pre;
background: transparent;
border: 0;
color: #abb2bf; // Atom One Dark 文本颜色
}
// 添加代码块的滚动条样式
:deep(pre)::-webkit-scrollbar {
width: 8px;
height: 8px;
}
:deep(pre)::-webkit-scrollbar-thumb {
background: #3a404b;
border-radius: 4px;
}
:deep(pre)::-webkit-scrollbar-track {
background: #282c34;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<div class="code-editor">
<textarea ref="textarea" v-show="false"></textarea>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import CodeMirror from 'codemirror';
import 'codemirror/lib/codemirror.css';
// 主题
import 'codemirror/theme/idea.css';
import 'codemirror/theme/nord.css';
import 'codemirror/theme/xq-light.css';
import 'codemirror/mode/clike/clike';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/addon/display/autorefresh';
// 搜索
import 'codemirror/addon/scroll/annotatescrollbar.js';
import 'codemirror/addon/search/matchesonscrollbar.js';
import 'codemirror/addon/search/match-highlighter.js';
import 'codemirror/addon/search/jump-to-line.js';
import 'codemirror/addon/dialog/dialog.js';
import 'codemirror/addon/dialog/dialog.css';
import 'codemirror/addon/search/searchcursor.js';
import 'codemirror/addon/search/search.js';
// 折叠
import 'codemirror/addon/fold/foldgutter.css';
import 'codemirror/addon/fold/foldcode';
import 'codemirror/addon/fold/foldgutter';
import 'codemirror/addon/fold/brace-fold';
import 'codemirror/addon/fold/comment-fold';
// 格式化
import formatter from '../utils/formatter';
import { validatejson, validatenull } from '../utils/validate';
import { ElMessage } from 'element-plus';
// 定义组件属性
const props = defineProps({
modelValue: {
type: String,
required: true,
default: '',
},
height: {
type: String,
required: true,
default: '450px',
},
mode: {
type: String,
default: 'javascript',
},
theme: {
type: String,
default: 'idea',
},
readonly: {
type: Boolean,
default: false,
},
json: {
type: Boolean,
default: false,
},
});
// 定义事件
const emit = defineEmits(['update:modelValue']);
// 定义DOM引用
const textarea = ref<HTMLTextAreaElement | null>(null);
// 编辑器实例
let editor: any = null;
// 格式化代码方法
const prettyCode = () => {
if (props.json && editor) {
const val = editor.getValue();
if (validatenull(val)) {
ElMessage.warning('请先填写数据');
return;
}
if (!validatejson(val)) {
ElMessage.warning('数据 JSON 格式错误');
return;
}
editor.setValue(formatter.prettyCode(val));
}
};
// 组件挂载后初始化编辑器
onMounted(() => {
editor = CodeMirror.fromTextArea(textarea.value, {
mode: props.mode,
theme: props.theme,
readOnly: props.readonly,
autoRefresh: true,
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
extraKeys: {
// 绑定快捷键 Ctrl-F / Cmd-F 开启搜索
'Ctrl-F': 'findPersistent',
'Cmd-F': 'findPersistent',
// 如果需要,您还可以添加 "Ctrl-R"/"Cmd-R" 绑定替换功能
'Ctrl-R': 'replace',
'Cmd-R': 'replace',
},
});
// 设置高度
editor.setSize('auto', props.height);
// 设置文本
const editorValue = props.json ? formatter.prettyCode(props.modelValue) : props.modelValue;
editor.setValue(editorValue);
// 监听变化
editor.on('change', () => {
emit('update:modelValue', editor.getValue());
});
});
// 监听属性变化
watch(
() => props.modelValue,
(newVal) => {
if (editor && newVal !== editor.getValue()) {
const editorValue = props.json ? formatter.prettyCode(props.modelValue) : props.modelValue;
editor.setValue(editorValue);
}
}
);
watch(
() => props.height,
(newHeight) => {
if (editor) {
editor.setSize('auto', newHeight);
}
}
);
// 暴露方法给父组件
defineExpose({
prettyCode,
});
</script>
<style scoped>
.code-editor {
line-height: 1.2 !important;
width: calc(100% - 4px);
height: 100%;
border: 1px solid #ccc; /* 添加边框样式 */
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div class="code-block-container">
<pre v-if="highlightedCode"><code v-html="highlightedCode" class="hljs"></code></pre>
<button v-if="clipboard" v-clipboard:copy="code" v-clipboard:success="onCopySuccess" class="copy-btn">一键复制</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import hljs from 'highlight.js';
import formatter from '../utils/formatter';
// 引入语言支持
import json from 'highlight.js/lib/languages/json';
import java from 'highlight.js/lib/languages/java';
import javascript from 'highlight.js/lib/languages/javascript';
import 'highlight.js/styles/github.css';
import { ElMessage } from 'element-plus';
// 定义组件属性
const props = defineProps({
code: {
type: String,
required: true,
},
language: {
type: String,
default: 'javascript',
},
clipboard: {
type: Boolean,
default: true,
},
});
// 注册语言
hljs.registerLanguage('json', json);
hljs.registerLanguage('java', java);
hljs.registerLanguage('javascript', javascript);
// 计算高亮代码
const highlightedCode = computed(() => {
let code = props.code;
if (props.language === 'json') {
code = formatter.prettyCode(props.code);
}
return hljs.highlight(code, { language: props.language, ignoreIllegals: true }).value;
});
// 复制成功回调
const onCopySuccess = () => {
ElMessage.success('复制成功');
};
</script>
<style scoped>
.code-block-container {
position: relative;
background-color: #f0f0f0;
width: 100%;
overflow-y: auto; /* 只显示纵向滚动条 */
overflow-x: hidden; /* 隐藏横向滚动条 */
white-space: pre-wrap; /* 防止内容溢出 */
word-wrap: break-word; /* 自动换行 */
}
pre {
margin: 0;
padding: 0.5em;
border-radius: 8px;
}
.copy-btn {
position: absolute;
top: 0.5em;
right: 0.5em;
padding: 0.25em 0.5em;
font-size: 0.75em;
color: #fff;
background-color: #606266;
border: none;
cursor: pointer;
border-radius: 4px;
}
.copy-btn:hover {
background-color: #909399;
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<div class="line-chart">
<svg :width="width" :height="height">
<!-- 绘制折线 -->
<path :d="pathD" fill="none" stroke="#67c23a" stroke-width="2" />
<!-- 绘制点 -->
<circle v-for="point in points" :key="point.x" :cx="point.x" :cy="point.y" r="4" fill="#67c23a" />
</svg>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
// 定义数据点接口
interface DataPoint {
value: number;
[key: string]: any;
}
// 定义坐标点接口
interface Point {
x: number;
y: number;
}
// 定义组件属性
const props = defineProps({
data: {
type: Array as () => DataPoint[],
required: true,
},
});
// 图表尺寸
const width = 500;
const height = 100;
const padding = 20;
// 计算坐标点
const points = computed(() => {
if (!props.data.length) return [];
const xStep = (width - padding * 2) / (props.data.length - 1);
const maxValue = Math.max(...props.data.map((d) => d.value));
const yScale = maxValue ? (height - padding * 2) / maxValue : 1;
return props.data.map((d, i) => ({
x: padding + i * xStep,
y: height - padding - d.value * yScale,
}));
});
// 生成SVG路径
const pathD = computed(() => {
if (!points.value.length) return '';
return points.value.reduce((path, point, i) => {
return path + (i === 0 ? `M ${point.x},${point.y}` : ` L ${point.x},${point.y}`);
}, '');
});
</script>
<style scoped>
.line-chart {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,87 @@
<!-- 基础容器组件 -->
<template>
<el-container class="basic-container">
<!-- 头部卡片区域 -->
<el-card v-if="$slots.header" class="header-card" shadow="never">
<div class="card-title" v-if="headerTitle">{{ headerTitle }}</div>
<slot name="header"></slot>
</el-card>
<!-- 主要内容区域 -->
<el-card class="content-card" shadow="never">
<div class="card-title" v-if="title">{{ title }}</div>
<div class="content" v-loading="loading">
<slot></slot>
</div>
</el-card>
</el-container>
</template>
<script setup lang="ts">
import { useSlots } from 'vue';
// 定义组件属性
defineProps({
// 主标题
title: {
type: String,
default: '',
},
// 头部标题
headerTitle: {
type: String,
default: '',
},
// 加载状态
loading: {
type: Boolean,
default: false,
},
});
// 获取插槽
const slots = useSlots();
</script>
<style lang="scss" scoped>
.basic-container {
flex-direction: column;
height: 100vh;
background-color: #f5f7fa;
/**padding: 20px;
gap: 20px;**/
box-sizing: border-box;
.header-card {
padding: 15px;
}
.content-card {
flex: 1;
overflow: auto;
padding: 20px;
}
.card-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 10px;
}
:deep(.el-card) {
border: none;
.el-card__header {
border-bottom: 1px solid var(--el-border-color-lighter);
}
.el-table {
--el-table-border-color: var(--el-border-color-lighter);
.el-table__cell {
padding: 8px 12px;
}
}
}
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<el-select v-model="selectedModel" class="w-full model-select" :disabled="disabled" @change="handleChange">
<template #label>
<div style="display: flex; align-items: center">
<img v-if="currentModelIcon" :src="currentModelIcon" class="icon-img" :alt="String(selectedModel)" />
<span>{{ selectedModel }}</span>
</div>
</template>
<el-option v-for="item in modelList" :key="item.value" :label="item.label" :value="item.label">
<template #default>
<div style="display: flex; align-items: center">
<svg-icon :size="24" class="param-icon" name="local-llm" />
<span>{{ item.label }}</span>
</div>
</template>
</el-option>
</el-select>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, PropType } from 'vue';
import { list } from '/@/api/knowledge/aiModel';
import { ElMessage } from 'element-plus';
// 定义模型接口
interface Model {
id: number;
modelName: string;
icon: string;
label?: string;
value?: string | number;
}
// 定义组件属性
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
type: {
type: Array as PropType<string[]>,
default: () => ['Chat', 'Reason'],
},
// v-model绑定值
modelValue: {
type: [String, Number],
required: true,
},
});
// 定义事件
const emit = defineEmits(['update:modelValue']);
// 模型列表数据
const modelList = ref<Model[]>([]);
// 计算属性:用于双向绑定
const selectedModel = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
// 计算当前选中模型的图标
const currentModelIcon = computed(() => {
const currentModel = modelList.value.find((item) => item.label === selectedModel.value);
return currentModel ? currentModel.icon : '';
});
// 获取模型列表数据
const getModelListData = async () => {
try {
// 调用后台API获取模型列表
const response = await list({ modelType: props.type });
if (response && response.data) {
// 处理数据添加label和value字段
modelList.value = response.data.map((item: any) => ({
...item,
label: item.name,
value: item.id,
}));
}
} catch (error) {
ElMessage.error('获取模型列表失败');
}
};
// 处理模型变更
const handleChange = (val: string | number) => {
emit('update:modelValue', val);
};
// 组件挂载时获取数据
onMounted(() => {
getModelListData();
});
</script>
<style lang="scss">
.model-select {
margin-left: 10px;
}
.icon-img {
width: 20px;
height: 20px;
margin-right: 8px;
}
</style>

View File

@@ -0,0 +1,173 @@
<!-- NodeList.vue -->
<template>
<div class="execution-progress">
<div v-for="node in nodes" :key="node.id" @click.stop="node.expanded = !node.expanded">
<div class="node-execution" :class="getNodeStatus(node)">
<div class="node-info">
<svg-icon :size="24" :class="['node-icon', 'node-icon--' + node.type]" :name="`local-${node.type}`" />
<span class="node-name">{{ node.name }}</span>
<div class="node-time">
<span>{{ formatDuration(node.duration) }}</span>
<span v-if="node.tokens"> · {{ node.tokens }} tokens</span>
</div>
</div>
<!-- 执行状态图标 -->
<div class="status-icon">
<el-icon :class="getStatusIconClass(node)">
<component :is="getStatusIcon(node)" />
</el-icon>
</div>
</div>
<div class="trace-info" v-if="node.expanded">
<!-- 添加输入输出展示 -->
<div class="io-container">
<!-- 输入数据 -->
<div class="io-section" v-if="node.input">
<div class="io-header" @click.stop="toggleIO(node.id, 'input')">
<el-icon :class="{ 'is-rotate': isIOExpanded(node.id, 'input') }">
<ArrowRight />
</el-icon>
<span>输入</span>
</div>
<div class="io-content" v-show="isIOExpanded(node.id, 'input')">
<pre>{{ formatJSON(node.input) }}</pre>
</div>
</div>
<!-- 输出数据 -->
<div class="io-section">
<div class="io-header" @click.stop="toggleIO(node.id, 'output')">
<el-icon :class="{ 'is-rotate': isIOExpanded(node.id, 'output') }">
<ArrowRight />
</el-icon>
<span>输出</span>
</div>
<div class="io-content" v-show="isIOExpanded(node.id, 'output')">
<pre>{{ node.error || formatJSON(node.output) }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Close, Loading, Check, CircleClose, ArrowRight } from '@element-plus/icons-vue';
// 定义节点接口
interface Node {
id: string | number;
name: string;
type: string;
status?: 'running' | 'success' | 'error' | 'skipped' | 'pending';
duration?: number;
tokens?: number;
error?: string;
input?: any;
output?: any;
expanded?: boolean;
}
// 定义IO展开状态接口
interface ExpandedIO {
[key: string]: {
[key: string]: boolean;
};
}
// 定义组件属性
const props = defineProps({
// 节点数据列表
nodes: {
type: Array as () => Node[],
required: true,
default: () => [],
},
});
// 定义事件
const emit = defineEmits(['end']);
// 存储输入输出展开状态
const expandedIO = ref<ExpandedIO>({});
// 监听节点变化
watch(
() => props.nodes,
(newNodes) => {
const lastNode = newNodes[newNodes.length - 1];
if (lastNode && (lastNode.status === 'success' || lastNode.status === 'error')) {
emit('end', lastNode.status);
}
},
{ deep: true }
);
// 获取节点状态样式
const getNodeStatus = (node: Node) => {
return {
running: node.status === 'running',
success: node.status === 'success',
error: node.status === 'error',
skipped: node.status === 'skipped',
pending: !node.status,
};
};
// 格式化持续时间
const formatDuration = (duration?: number) => {
if (!duration) return '0ms';
if (duration < 1000) return `${duration}ms`;
return `${(duration / 1000).toFixed(3)}s`;
};
// 检查输入输出是否展开
const isIOExpanded = (nodeId: string | number, type: string) => {
if (!expandedIO.value[nodeId]) {
return true;
}
return expandedIO.value[nodeId][type] !== false;
};
// 切换输入输出的展开状态
const toggleIO = (nodeId: string | number, type: string) => {
if (!expandedIO.value[nodeId]) {
expandedIO.value[nodeId] = {};
}
const currentState = expandedIO.value[nodeId][type] !== false;
expandedIO.value[nodeId][type] = !currentState;
};
// 格式化 JSON 数据
const formatJSON = (data: any) => {
try {
return JSON.stringify(data, null, 2);
} catch (e) {
return data;
}
};
// 获取状态图标组件
const getStatusIcon = (node: Node) => {
const iconMap = {
running: Loading,
success: Check,
error: CircleClose,
};
return iconMap[node.status as keyof typeof iconMap] || null;
};
// 获取状态图标的类名
const getStatusIconClass = (node: Node) => {
return {
'is-loading': node.status === 'running',
};
};
</script>
<style scoped lang="scss">
@use '../styles/flow.scss';
</style>

View File

@@ -0,0 +1,75 @@
<!--
* @Description: 全屏遮罩组件用于提示应用未发布状态
* @Author: Claude
* @Date: 2024-02-07
-->
<template>
<div v-if="visible" class="unpublished-mask">
<div class="mask-content">
<el-icon class="warning-icon"><WarningFilled /></el-icon>
<h2 class="title">应用未发布</h2>
<p class="description">当前应用尚未发布请先发布应用后再进行操作</p>
</div>
</div>
</template>
<script setup lang="ts">
import { WarningFilled } from '@element-plus/icons-vue';
// 定义组件属性
defineProps({
visible: {
type: Boolean,
default: false,
},
});
</script>
<style lang="scss" scoped>
.unpublished-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
.mask-content {
background-color: #fff;
padding: 40px;
border-radius: 8px;
text-align: center;
max-width: 400px;
width: 90%;
}
.warning-icon {
font-size: 48px;
color: #e6a23c;
margin-bottom: 16px;
}
.title {
font-size: 24px;
color: #303133;
margin-bottom: 16px;
font-weight: 500;
}
.description {
font-size: 16px;
color: #606266;
margin-bottom: 24px;
line-height: 1.5;
}
.publish-btn {
padding: 12px 24px;
font-size: 16px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,178 @@
<template>
<el-dialog :title="dialogTitle" v-model="dialogVisible" :width="650" :close-on-click-modal="false" draggable>
<el-form :ref="(ref: any) => formRef = ref" :model="formData" :rules="formRules" label-width="90px" label-position="top" v-loading="loading">
<el-form-item label="编排名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入编排名称"></el-input>
</el-form-item>
<el-form-item label="编排描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
placeholder="描述该编排的应用场景及用途XXX 小助手回答用户提出的 XXX 产品使用问题"
></el-input>
</el-form-item>
<el-form-item v-if="dialogType === 'add'" label="选择应用类型" prop="type">
<el-radio-group v-model="formData.type" class="flex gap-3 w-full">
<el-radio :label="EOrchestrationType.WORK_FLOW" size="large" border class="!h-auto flex-1 m-0">
<div class="py-2">
<div class="leading-6 text-gray-500 text-md">高级编排</div>
<div class="text-sm leading-5 text-gray-400">适合高级用户自定义AI业务流</div>
</div>
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitHandle" :disabled="loading">
<template v-if="dialogType === 'copy'">复制</template>
<template v-else>确认</template>
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import {
IOrchestrationScope,
fetchOrchestrationScope,
fetchList,
orchestrationRemove,
IOrchestrationItem,
EOrchestrationType,
addObj,
getObj,
putObj,
orchestrationExportContent,
orchestrationImportContent,
} from '/@/api/knowledge/aiFlow';
import { cloneDeep, merge, pick } from 'lodash';
import { useMessage, useMessageBox } from '/@/hooks/message';
import { FormRules } from 'element-plus';
import { rule } from '/@/utils/validate';
type IItem = IOrchestrationItem & { originId?: string } /* 用于复制时存储原始id */;
interface State {
dialogVisible: boolean;
loading: boolean;
dialogType: 'add' | 'edit' | 'copy';
formData: IItem;
formRules: FormRules<IOrchestrationItem>;
formRef: any;
}
const getDefaultFormData = (obj?: Partial<IItem>): IItem => {
return merge(
{
id: undefined,
name: '',
description: '',
type: EOrchestrationType.WORK_FLOW,
},
obj || {}
);
};
const emit = defineEmits(['refresh']);
const state = reactive<State>({
dialogVisible: false,
loading: false,
formData: getDefaultFormData(),
dialogType: 'add',
formRules: {
name: [
{ required: true, message: '请输入编排名称', trigger: 'blur' },
{ validator: rule.overLength, trigger: 'blur' },
],
description: [{ validator: rule.overLength, trigger: 'blur' }],
},
formRef: undefined,
});
const { dialogVisible, loading, formData, formRules, formRef, dialogType } = toRefs(state);
const dialogTitle = computed(() => {
switch (state.dialogType) {
case 'copy':
return '复制';
case 'edit':
return '编辑';
default:
return '新增';
}
});
/**
* @description : 初始化表单
* @param {any} id
* @return {any}
*/
async function init(id?: string) {
state.loading = true;
state.formData = getDefaultFormData({ id });
state.formRef.resetFields();
try {
if (state.dialogType !== 'add' && id) {
const res = await getObj(id);
if (state.dialogType === 'copy') {
state.formData = {
...pick(res.data, ['name', 'description']),
originId: id,
};
} else {
state.formData = getDefaultFormData(res.data);
}
}
} catch (e: any) {
useMessage().error(e.message || '系统异常请联系管理员');
}
state.loading = false;
}
/**
* @description : 提交表单
* @return {any}
*/
async function submitHandle() {
const valid = await state.formRef.validate().catch(() => {});
if (!valid) return false;
state.loading = true;
const form = cloneDeep(state.formData);
try {
if (state.dialogType === 'add') {
await addObj(form);
} else if (state.dialogType === 'copy') {
await copy(form.originId as string, pick(form, ['name', 'description']));
} else {
await putObj(form);
}
useMessage().success(dialogTitle.value + '成功');
state.dialogVisible = false;
emit('refresh');
} catch (e: any) {
useMessage().error(e.message || '系统异常请联系管理员');
}
state.loading = false;
}
defineExpose({
/**
* @description : 打开 dialog 窗口,并初始化窗口
* @param {string} id
* @param {'add' | 'edit' | 'copy'} type 默认 add
* @return {any}
*/
openDialog: async (id?: string, type: 'add' | 'edit' | 'copy' = 'add') => {
if (!['add', 'edit', 'copy'].includes(type)) {
useMessageBox().error('type参数错误');
return false;
}
dialogVisible.value = true;
state.dialogType = type;
await nextTick();
init(id);
},
});
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,337 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-row v-if="showSearch && tableState.queryForm">
<el-form :model="tableState.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
<el-form-item label="业务名称" prop="name">
<el-input v-model="tableState.queryForm.name" placeholder="请输入名称搜索" />
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="getDataList"> 查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="formRef.openDialog()"> </el-button>
<el-button icon="upload" type="default" class="ml10" @click="uploadDialogVisible = true"> </el-button>
<right-toolbar
v-model:showSearch="showSearch"
:export="false"
class="ml10 mr20"
style="float: right"
@exportExcel="exportExcel"
@queryTable="getDataList"
></right-toolbar>
</div>
</el-row>
<el-scrollbar class="h-[calc(100vh-280px)] mb-4">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div
v-for="dataset in tableState.dataList"
:key="dataset.id"
class="group overflow-hidden bg-white rounded-lg shadow-sm border border-gray-100 transition-all duration-300 cursor-pointer dark:bg-gray-800 dark:border-gray-700 hover:shadow-lg hover:border-primary-100 hover:translate-y-[-2px]"
@click="openSettingDialog(dataset)"
>
<div class="p-5">
<div class="flex items-start">
<div
class="flex items-center justify-center text-lg font-medium text-white transition-transform rounded-lg size-12 group-hover:scale-110"
:class="dataset.type === EOrchestrationType.WORK_FLOW ? 'bg-amber-500' : 'bg-indigo-600'"
>
{{ dataset.name ? dataset.name.substring(0, 1).toUpperCase() : '' }}
</div>
<div class="flex-1 ml-3 overflow-hidden">
<div class="text-base font-medium text-gray-900 truncate dark:text-white">
{{ dataset.name }}
</div>
<div class="flex items-center mt-1 text-xs text-gray-500 dark:text-gray-400">
<el-icon class="mr-1"><User /></el-icon>
{{ dataset.createBy }}
</div>
</div>
<div>
<el-tag type="primary" size="small" class="ml-2" effect="light"> 高级编排 </el-tag>
</div>
</div>
<div class="h-16 mt-4 overflow-y-auto text-sm text-gray-600 dark:text-gray-300 line-clamp-3">
{{ dataset.description || '暂无描述' }}
</div>
<div class="flex items-center justify-start pt-3 mt-4 border-t border-gray-100 dark:border-gray-700">
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
@click.stop="router.push(`/aiFlow/process/${dataset.id}`)"
>
<el-icon><DataLine /></el-icon>
</el-button>
<div class="w-px h-4 mx-2 bg-gray-200 dark:bg-gray-700"></div>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
@click.stop="formRef.openDialog(`${dataset.id}`, 'edit')"
>
<el-icon><Setting /></el-icon>
</el-button>
<div class="w-px h-4 mx-2 bg-gray-200 dark:bg-gray-700" v-if="dataset.enabled === '1'"></div>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
v-if="dataset.enabled === '1'"
@click.stop="router.push(`/knowledge/aiChat/index?datasetId=-9&flowId=${dataset.id}`)"
>
<el-icon><ChatDotRound /></el-icon>
</el-button>
<div class="w-px h-4 mx-2 bg-gray-200 dark:bg-gray-700"></div>
<el-dropdown trigger="click" @command="(command: string) => itemDropdownHandle(dataset, command)">
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
@click.stop
>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="copy">复制</el-dropdown-item>
<el-dropdown-item command="export">导出</el-dropdown-item>
<el-dropdown-item command="remove">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div class="flex-grow"></div>
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400">
<el-icon class="mr-1"><Clock /></el-icon>
{{ parseDate(dataset.createTime) }}
</div>
</div>
</div>
</div>
</div>
</el-scrollbar>
<!-- 无数据显示 -->
<el-empty v-if="!tableState.dataList || tableState.dataList.length === 0" description="暂无数据"></el-empty>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="tableState.pagination" />
</div>
<Form ref="formRef" @refresh="getDataList" />
<!-- 上传对话框 -->
<el-dialog v-model="uploadDialogVisible" title="导入文件" width="500px" destroy-on-close>
<upload-file
ref="elUploadRef"
:file-list="[]"
:auto-upload="false"
:limit="1"
:file-type="['dsl']"
uploadFileUrl="/knowledge/aiFlow/import"
@change="importHandle"
/>
<template #footer>
<span class="dialog-footer">
<el-button @click="uploadDialogVisible = false"> </el-button>
<el-button type="primary" @click="submitUpload"> </el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { DataLine, More, Setting, User, ChatDotRound } from '@element-plus/icons-vue';
import {
IOrchestrationScope,
delObjs,
fetchList,
IOrchestrationItem,
EOrchestrationType,
exportFlow,
copyFlow,
importFlow,
} from '/@/api/knowledge/aiFlow';
import { useMessage, useMessageBox } from '/@/hooks/message';
import { BasicTableProps, useTable } from '/@/hooks/table';
import Form from './form.vue';
import { ElNotification } from 'element-plus';
interface State {
showSearch: boolean;
scopeList: IOrchestrationScope[];
queryForm: {
selectUserId?: string;
name?: string;
};
formRef?: any;
elUploadRef?: any;
}
const router = useRouter();
const queryRef = ref();
const uploadDialogVisible = ref(false);
const elUploadRef = ref();
const state = reactive<State>({
showSearch: true,
scopeList: [],
queryForm: {
selectUserId: undefined,
name: undefined,
},
formRef: undefined,
});
const tableState = reactive<BasicTableProps>({
queryForm: {},
pageList: fetchList,
dataList: [],
pagination: {},
});
const { showSearch, formRef } = toRefs(state);
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle /* , sortChangeHandle, downBlobFile, tableStyle */ } = useTable(tableState);
/**
* @description : 导出
* @return {any}
*/
function exportExcel() {}
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
queryRef.value?.resetFields();
tableState.queryForm = {};
getDataList();
};
function openSettingDialog(dataset: IOrchestrationItem) {
router.push({
path: `/aiFlow/process/${dataset.id}`,
});
}
async function itemDropdownHandle(dataset: IOrchestrationItem, command: string) {
switch (command) {
case 'copy':
copyHandle(dataset.id as string);
break;
case 'remove':
deleteHandle(dataset.id as string);
break;
case 'export':
exportHandle(dataset);
break;
}
}
/**
* @description : 导出 方法
* @param {any} dataset
* @return {any}
*/
async function exportHandle(dataset: IOrchestrationItem) {
try {
if (!dataset.id || !dataset.name) {
throw new Error('编排ID或名称不能为空');
}
await exportFlow(dataset.id, dataset.name);
} catch (e: any) {
useMessage().error(e.msg || e.message);
}
}
async function copyHandle(id: string) {
try {
await copyFlow(id);
getDataList();
useMessage().success('复制成功');
} catch (e: any) {
useMessage().error(e.msg || e.message);
}
}
/**
* @description : 删除方法。
* @param {string | string[]} idOrIds id 或 id[]
* @return {any}
*/
async function deleteHandle(idOrIds: string | string[]) {
try {
await useMessageBox().confirm('此操作将永久删除');
} catch {
return;
}
try {
await delObjs([idOrIds]);
getDataList();
useMessage().success('删除成功');
} catch (err: any) {
useMessage().error(err.msg);
}
}
/**
* @description : 导入
* @param {any} file
* @return {any}
*/
async function importHandle() {
try {
useMessage().success('导入成功');
getDataList();
uploadDialogVisible.value = false;
} catch (e: any) {
useMessage().error(e.msg || e.message);
}
}
/**
* 提交上传
*/
function submitUpload() {
if (elUploadRef.value) {
elUploadRef.value.submit();
}
uploadDialogVisible.value = false;
}
</script>
<style lang="scss" scoped>
:deep(.el-scrollbar__wrap) {
overflow-x: hidden !important;
}
.bg-primary-100 {
background-color: var(--el-color-primary-light-9);
}
.text-primary {
color: var(--el-color-primary);
}
:deep(.el-button.is-text) {
padding: 0;
&:hover {
background-color: transparent;
}
}
</style>

View File

@@ -0,0 +1,497 @@
import { Node, Connection, ParamItem, ExecutionNode, ExecutionContext, NodeData } from '../types/node';
import { executeFlow, executeFlowSSEWithChat, FlowExecutionCallbacks, FlowExecutionEvent, FlowExecutionResult } from '/@/api/knowledge/aiFlow';
import { generateUUID } from '/@/utils/other';
// Define the mixin as a Vue component options object
export default {
data(): NodeData {
return {
conversationId: '',
isRunning: false,
id: null,
form: {},
env: [],
nodes: [],
connections: [],
isStream: true,
};
},
async created(this: any) {
// 从路由参数中获取id
this.id = this.$route.params.id;
},
computed: {
/**
* 计算工作流的执行顺序
* @returns {Node[]} 按执行顺序排列的节点数组
*/
workflowExecutionOrder(this: any): Node[] {
// 初始化访问记录和执行顺序数组
const visited = new Set<string>();
const executionOrder: Node[] = [];
// 解构获取节点和连接信息
const { nodes, connections } = this;
// 查找开始节点
const startNode = nodes.find((node: Node) => node.type === 'start');
if (!startNode) {
// 使用this.$log替代console以避免linter错误
this.$log?.warn?.('未找到开始节点');
return [];
}
/**
* 深度优先搜索遍历节点
* @param {string} nodeId - 当前节点ID
* @param {string[]} path - 当前路径
* @param {number|null} branchIndex - 分支索引
*/
const dfs = (nodeId: string, path: string[] = [], branchIndex: number | null = null): void => {
// 检查循环依赖
if (visited.has(nodeId)) {
if (path.includes(nodeId)) {
const cycleNodes: string[] = path.slice(path.indexOf(nodeId));
// 使用this.$log替代console以避免linter错误
this.$log?.warn?.('检测到循环依赖,涉及节点:', cycleNodes);
return;
}
return;
}
// 获取当前节点
const currentNode = nodes.find((node: Node) => node.id === nodeId);
if (!currentNode) {
// 使用this.$log替代console以避免linter错误
this.$log?.warn?.('未找到节点:', nodeId);
return;
}
// 标记节点为已访问
visited.add(nodeId);
path.push(nodeId);
// 设置分支信息
if (branchIndex !== null) {
currentNode._branchIndex = branchIndex;
}
// 添加到执行顺序
executionOrder.push(currentNode);
// 获取所有出边并按端口索引排序
const outgoingConnections = connections
.filter((conn: Connection) => conn.sourceId === nodeId)
.sort((a: Connection, b: Connection) => (a.portIndex || 0) - (b.portIndex || 0));
// 根据节点类型处理后续节点
if (['switch', 'question'].includes(currentNode.type)) {
// 分支节点:为每个分支的节点添加分支索引
outgoingConnections.forEach((conn: Connection) => {
dfs(conn.targetId, [...path], conn.portIndex || 0);
});
} else {
// 普通节点:继承当前分支索引
outgoingConnections.forEach((conn: Connection) => {
dfs(conn.targetId, [...path], branchIndex);
});
}
};
// 从开始节点开始遍历
dfs(startNode.id);
return executionOrder;
},
},
methods: {
/**
* 深拷贝对象
* @param {T} obj - 要拷贝的对象
* @returns {T} 拷贝后的对象
*/
deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
},
/**
* 初始化全局环境变量
*/
initGlobalEnv(this: any): void {
// 确保 window.$glob 存在
if (!window.$glob) window.$glob = {};
// 确保 this.env 是一个数组
if (!this.env || !Array.isArray(this.env)) {
this.env = [];
return;
}
this.env.forEach((item: { name: string; value: any }) => {
if (item.name) window.$glob[item.name] = item.value;
});
},
/**
* 重置会话,在关闭面板时调用
*/
resetConversation(this: any): void {
this.conversationId = '';
},
/**
* 处理工具栏运行按钮点击
*/
handleRunClick(this: any): void {
// 如果没有会话ID则创建一个
if (!this.conversationId) {
this.conversationId = generateUUID();
}
// 获取开始节点
const startNode = this.nodes.find((node: Node) => node.type === 'start');
if (startNode?.outputParams?.length) {
this.startNodeParams = this.deepClone(startNode.inputParams);
this.showExecutionPanel = true;
} else {
// 如果没有参数,直接运行
this.runWorkflow();
}
},
/**
* 使用SSE运行工作流实时状态更新版本
* @param {ParamItem[] | null} params - 开始节点参数
* @returns {Promise<ExecutionContext>} 执行上下文
*/
async runWorkflowSSE(this: any, params: ParamItem[] | null = null): Promise<ExecutionContext> {
this.isRunning = true;
this.initGlobalEnv();
this.showExecutionPanel = true;
const workflowExecutionOrder = this.deepClone(this.workflowExecutionOrder);
this.executionNodes = [];
const workflowStartTime = performance.now();
try {
const executionOrder = workflowExecutionOrder;
const firstNode = executionOrder[0];
firstNode.inputParams = [];
const context: ExecutionContext = {
variables: {},
params: {},
envs: {},
};
// 如果存在环境变量,也添加到开始节点的输入参数中
if (this.env && Array.isArray(this.env) && this.env.length > 0) {
this.env.forEach((item: { name: string; value: any }) => {
if (!item.name) return;
const envKey = `global.${item.name}`;
context.envs[envKey] = item.value;
firstNode.inputParams!.push({ type: envKey });
});
}
// 如果有开始节点参数,设置到上下文中
if (params) {
params.forEach((param) => {
const key = `${firstNode.id}.${param.key || param.type}`;
context.params[key] = param.value;
firstNode.inputParams!.push({ type: key });
});
}
// 根据isStream属性决定使用SSE还是普通请求
if (this.isStream) {
// 使用SSE聊天执行工作流
await this.executeWithSSE(context);
} else {
// 使用普通请求
await this.executeWithHTTP(context);
}
return context;
} catch (error: any) {
this.$log?.error?.('运行工作流失败:', error);
this.executionResult = { error: error.message };
const workflowEndTime = performance.now();
const totalDuration = workflowEndTime - workflowStartTime;
this.$nextTick(() => {
this.executionTime = totalDuration.toFixed(3);
this.isRunning = false;
});
throw error;
}
},
/**
* 使用SSE方式执行工作流
* @param {ExecutionContext} context - 执行上下文
*/
async executeWithSSE(this: any, context: ExecutionContext): Promise<void> {
// 初始化聊天消息累积器
let chatMessageContent = '';
let isChatStreaming = false;
// 设置SSE回调函数
const callbacks: FlowExecutionCallbacks = {
onStart: () => {
this.$log?.info?.('工作流开始执行');
// 重置聊天消息
chatMessageContent = '';
isChatStreaming = false;
},
onProgress: (event: FlowExecutionEvent) => {
this.$log?.info?.(`节点执行进度: ${event.nodeName} (${event.nodeId})`);
// 更新执行节点状态
if (event.nodeId && this.executionNodes) {
const nodeIndex = this.executionNodes.findIndex((n: ExecutionNode) => n.id === event.nodeId);
if (nodeIndex >= 0) {
this.$nextTick(() => {
this.executionNodes[nodeIndex] = {
...this.executionNodes[nodeIndex],
status: 'running',
...event.data
};
});
}
}
// 可以在这里添加进度条或其他UI更新
if (event.progress !== undefined) {
this.$log?.info?.(`执行进度: ${event.progress}%`);
}
},
onChatMessage: (content: string, isComplete: boolean, tokens?: number, duration?: number, nodes?: Array<any>) => {
// 累积聊天消息内容
chatMessageContent += content;
isChatStreaming = true;
// 处理 tokens、duration 和 nodes 信息
if (isComplete) {
if (tokens) {
this.totalTokens = tokens;
}
if (duration) {
this.executionTime = duration;
}
if (nodes) {
this.executionNodes = nodes;
}
}
// 实时更新executionResult以显示聊天消息
this.$nextTick(() => {
this.executionResult = {
...this.executionResult,
chatMessage: chatMessageContent,
isStreaming: !isComplete
};
});
},
onComplete: (result: FlowExecutionResult) => {
this.$log?.info?.('工作流执行完成');
this.$nextTick(() => {
this.executionNodes = result.nodes;
// 如果有聊天消息,将其合并到结果中
if (isChatStreaming && chatMessageContent) {
this.executionResult = {
...result.result,
chatMessage: chatMessageContent,
isStreaming: false
};
} else {
this.executionResult = result.result;
}
this.executionTime = Number(result.duration);
this.totalTokens = Number(result.totalTokens || 0);
this.isRunning = false;
});
},
onError: (error: string) => {
this.$log?.error?.('工作流执行失败:', error);
this.$nextTick(() => {
// 如果有部分聊天消息,也保留在错误结果中
const errorResult: any = { error };
if (isChatStreaming && chatMessageContent) {
errorResult.chatMessage = chatMessageContent;
errorResult.isStreaming = false;
}
this.executionResult = errorResult;
this.isRunning = false;
});
},
};
const chatResult = await executeFlowSSEWithChat(
{ id: this.id, conversationId: this.conversationId, params: context.params, envs: context.envs, stream: true },
callbacks
);
// 将聊天结果写入executionResult
this.$nextTick(() => {
this.executionResult = {
chatMessage: chatResult.chatMessage,
result: chatResult.result,
isStreaming: false
};
this.isRunning = false;
});
},
/**
* 使用普通HTTP请求执行工作流
* @param {ExecutionContext} context - 执行上下文
*/
async executeWithHTTP(this: any, context: ExecutionContext): Promise<void> {
const { data } = await executeFlow({
id: this.id,
params: context.params,
envs: context.envs,
stream: false
});
// 处理普通JSON响应
this.$nextTick(() => {
// 检查响应数据结构,支持不同的返回格式
if (data.nodes && data.result !== undefined) {
// 标准格式: { nodes, result, duration, totalTokens }
this.executionNodes = data.nodes;
this.executionResult = data.result;
this.executionTime = Number(data.duration || 0);
this.totalTokens = Number(data.totalTokens || 0);
} else if (data.data) {
// 嵌套格式: { data: { nodes, result, duration, totalTokens } }
const responseData = data.data;
this.executionNodes = responseData.nodes || [];
this.executionResult = responseData.result || responseData;
this.executionTime = Number(responseData.duration || 0);
this.totalTokens = Number(responseData.totalTokens || 0);
} else {
// 直接返回格式:直接使用 data 作为结果
this.executionNodes = data.nodes || [];
this.executionResult = data;
this.executionTime = Number(data.duration || 0);
this.totalTokens = Number(data.totalTokens || 0);
}
// 如果结果包含聊天消息内容,也进行处理
if (this.executionResult && typeof this.executionResult === 'object') {
// 检查是否有聊天消息相关字段
if (this.executionResult.chatMessage || this.executionResult.content) {
const chatMessage = this.executionResult.chatMessage || this.executionResult.content;
this.executionResult = {
...this.executionResult,
chatMessage: chatMessage,
isStreaming: false
};
}
}
this.isRunning = false;
});
},
/**
* 运行工作流
* @param {ParamItem[] | null} params - 开始节点参数
* @returns {Promise<ExecutionContext>} 执行上下文
*/
async runWorkflow(this: any, params: ParamItem[] | null = null): Promise<ExecutionContext> {
// 默认使用SSE版本
return this.runWorkflowSSE(params);
},
/**
* 判断节点是否应该被跳过
* @param {Node} node - 当前节点
* @param {Map<string, number>} activeBranches - 活动分支信息
* @returns {boolean} 是否应该跳过
*/
shouldSkipNode(this: any, node: Node, activeBranches: Map<string, number>): boolean {
// 如果节点没有分支信息,不跳过
if (node._branchIndex === undefined) {
return false;
}
// 查找当前节点所属的分支节点
const branchNodeId = this.findBranchNodeId(node);
if (!branchNodeId) {
return false;
}
// 获取活动分支索引
const activeBranch = activeBranches.get(branchNodeId);
// 如果找不到活动分支信息,或者分支索引匹配,则不跳过
if (activeBranch === undefined || activeBranch === node._branchIndex) {
return false;
}
return true;
},
/**
* 查找节点所属的分支节点ID
* @param {Node} node - 当前节点
* @returns {string|null} 分支节点ID
*/
findBranchNodeId(this: any, node: Node): string | null {
// 遍历所有连接找到源节点
const connection = this.connections.find((conn: Connection) => conn.targetId === node.id);
if (!connection) {
return null;
}
const sourceNode = this.nodes.find((n: Node) => n.id === connection.sourceId);
if (!sourceNode) {
return null;
}
// 如果源节点是分支节点返回其ID
if (['switch', 'question'].includes(sourceNode.type)) {
return sourceNode.id;
}
// 递归查找
return this.findBranchNodeId(sourceNode);
},
/**
* 解析节点的输入参数
* @param {Node} node - 当前节点
* @param {ExecutionContext} context - 执行上下文
* @returns {Record<string, any>} 解析后的输入参数
*/
resolveNodeInputParams(this: any, node: Node, context: ExecutionContext): Record<string, any> {
const inputParams: Record<string, any> = {
id: node.id,
};
let list = this.deepClone(node.inputParams || []);
if (node.type === 'http') {
if (node.bodyParams) list = list.concat(node.bodyParams);
if (node.headerParams) list = list.concat(node.headerParams);
}
list?.forEach((ele: ParamItem) => {
const { value, name, type } = ele;
const key = `${node.id}.${name}`;
if (type && type.includes('global')) {
inputParams[type] = window.$glob[type.replace('global.', '')];
context.params[type] = inputParams[type];
} else if (type) {
inputParams[type] = context.params[type];
} else if (value) {
inputParams[key] = value;
context.params[key] = value;
}
});
return inputParams;
},
},
};

View File

@@ -0,0 +1,50 @@
<template>
<div>
<div class="output-params">
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
<svg-icon :size="24" class="param-icon" name="local-var" />
<span class="param-name">{{ param.name }}</span>
</div>
</div>
</div>
</template>
<script>
import common from './common.ts';
export default {
name: 'CodeNode',
mixins: [common],
};
</script>
<style scoped>
/* 添加样式 */
.output-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
color: #666;
margin-right: 4px;
}
.param-value {
color: #333;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div>
<div class="output-params">
<div class="param-item">
<svg-icon :size="24" class="param-icon" name="local-db" />
<span class="param-value">{{ node.dbParams.dbName }}</span>
</div>
</div>
</div>
</template>
<script>
import common from './common.ts';
export default {
name: 'DbNode',
mixins: [common],
};
</script>
<style scoped>
/* 添加样式 */
.output-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
padding: 2px 5px;
background-color: #fff;
box-sizing: border-box;
margin-right: 4px;
font-size: 10px;
font-weight: bold;
border-radius: 4px;
}
.param-value {
color: #666;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div>
<div class="output-params">
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
<svg-icon :size="24" class="param-icon" name="local-var" />
<span class="param-name">{{ param.name }}</span>
</div>
</div>
</div>
</template>
<script>
import common from './common.ts';
export default {
name: 'EndNode',
mixins: [common],
};
</script>
<style scoped>
/* 添加样式 */
.output-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
color: #666;
margin-right: 4px;
}
.param-value {
color: #333;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div class="output-params"
v-if="node.method || node.httpParams.url">
<div class="param-item">
<span class="param-name">{{ node.httpParams.method }}</span>
<span class="param-value">{{ node.httpParams.url }}</span>
</div>
</div>
</template>
<script>
import common from './common.ts'
export default {
name: 'HttpNode',
mixins: [common]
}
</script>
<style scoped>
/* 添加样式 */
.output-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
overflow: hidden;
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
padding: 2px 5px;
background-color: #fff;
box-sizing: border-box;
margin-right: 4px;
font-size: 10px;
font-weight: bold;
border-radius: 4px;
}
.param-value {
color: #666;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div>
<div class="output-params">
<div class="input-params">
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
<svg-icon :size="24" class="param-icon" name="local-var" />
<span class="param-name">{{ param.name }}</span>
</div>
</div>
<div class="param-item">
<svg-icon :size="24" class="param-icon" name="local-llm" />
<span class="param-value">{{ node.llmParams.modelConfig.model }}</span>
</div>
</div>
</div>
</template>
<script>
import common from './common.ts';
export default {
name: 'LlmNode',
mixins: [common],
};
</script>
<style scoped>
/* 添加样式 */
.output-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
padding: 2px 5px;
box-sizing: border-box;
margin-right: 4px;
font-size: 10px;
font-weight: bold;
border-radius: 4px;
}
.param-value {
color: #666;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div>
<div class="output-params">
<div class="input-params">
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
<svg-icon :size="24" class="param-icon" name="local-var" />
<span class="param-name">{{ param.name }}</span>
</div>
</div>
<div class="param-item">
<svg-icon :size="24" class="param-icon" name="local-mcp" />
<span class="param-value">{{ node.mcpParams.mcpName || node.mcpParams.mcpId }}</span>
</div>
</div>
</div>
</template>
<script>
import common from './common.ts';
export default {
name: 'McpNode',
mixins: [common],
};
</script>
<style scoped>
/* 添加样式 */
.output-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
padding: 2px 5px;
box-sizing: border-box;
margin-right: 4px;
font-size: 10px;
font-weight: bold;
border-radius: 4px;
}
.param-value {
color: #666;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div>
<div class="output-params">
<div class="input-params">
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
<svg-icon :size="24" class="param-icon" name="local-var" />
<span class="param-name">{{ param.name }}</span>
</div>
</div>
</div>
</div>
</template>
<script>
import common from './common.ts';
export default {
name: 'NoticeNode',
mixins: [common],
};
</script>
<style scoped>
/* 添加样式 */
.output-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
padding: 2px 5px;
background-color: #fff;
box-sizing: border-box;
margin-right: 4px;
font-size: 10px;
font-weight: bold;
border-radius: 4px;
}
.param-value {
color: #666;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div class="question-node">
<!-- 输入参数展示 -->
<div class="input-params">
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
<svg-icon :size="24" class="param-icon" name="local-var" />
<span class="param-name">{{ param.name }}</span>
</div>
<div class="param-item">
<svg-icon :size="24" class="param-icon" name="local-llm" />
<span class="param-value">{{ node.questionParams.modelConfig.model }}</span>
</div>
</div>
<!-- 分类列表展示 -->
<div class="node-info">
<div class="info-item">
<div class="categories-list">
<div v-for="(item, index) in node.questionParams.categories" :key="index" class="category-item">
<div class="category-header">
<span class="category-name">{{ `分类${item.name}` || `分类${index + 1}` }}</span>
<span class="category-index">{{ item.value }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import common from './common.ts';
export default {
name: 'QuestionNode',
mixins: [common],
props: {
node: {
type: Object,
required: true,
},
},
};
</script>
<style lang="scss" scoped>
.question-node {
font-size: 12px;
}
.input-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
color: #666;
margin-right: 4px;
}
.node-info {
padding: 5px 10px;
box-sizing: border-box;
width: 100%;
.info-item {
width: 100%;
.categories-list {
width: 100%;
.category-item {
width: 100%;
padding: 4px 8px;
box-sizing: border-box;
background: #f8fafc;
margin-bottom: 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
.category-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.category-name {
font-weight: 500;
color: #1e293b;
}
.category-index {
font-size: 11px;
color: #64748b;
background: #e2e8f0;
padding: 2px 6px;
border-radius: 10px;
}
}
.category-desc {
color: #64748b;
font-size: 11px;
padding: 2px 0;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div>
<div class="output-params">
<div class="param-item">
<svg-icon :size="24" class="param-icon" name="local-db" />
<span class="param-value">{{ node.ragParams.datasetName }}</span>
</div>
</div>
</div>
</template>
<script>
import common from './common.ts';
export default {
name: 'RagNode',
mixins: [common],
};
</script>
<style scoped>
/* 添加样式 */
.output-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
padding: 2px 5px;
background-color: #fff;
box-sizing: border-box;
margin-right: 4px;
font-size: 10px;
font-weight: bold;
border-radius: 4px;
}
.param-value {
color: #666;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div>
<div class="output-params">
<div v-for="(param, index) in node.outputParams" :key="index" class="param-item">
<svg-icon :size="24" class="param-icon" name="local-var" />
<span class="param-name">{{ param.name }}</span>
</div>
</div>
</div>
</template>
<script>
import common from './common.ts';
export default {
name: 'StartNode',
mixins: [common],
};
</script>
<style scoped>
/* 添加样式 */
.output-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
color: #666;
margin-right: 4px;
}
.param-value {
color: #333;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div class="switch-node">
<div class="output-params">
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
<svg-icon :size="24" class="param-icon" name="local-var" />
<span class="param-name">{{ param.name }}</span>
</div>
</div>
<div class="node-info">
<div class="info-item">
<div class="cases-list">
<div v-for="(item, index) in node.switchParams.cases" :key="index" class="case-item">
<div class="case-header">
<span class="case-name">{{ item.name || `分支${index + 1}` }}</span>
<span class="case-index">{{ item.value }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import common from './common.ts';
export default {
name: 'SwitchNode',
mixins: [common],
props: {
node: {
type: Object,
required: true,
},
},
};
</script>
<style lang="scss" scoped>
/* 添加样式 */
.output-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
color: #666;
margin-right: 4px;
}
.param-value {
color: #333;
font-weight: 500;
}
.switch-node {
font-size: 12px;
}
.node-info {
padding: 5px 10px;
box-sizing: border-box;
width: 100%;
.info-item {
width: 100%;
.cases-list {
width: 100%;
.case-item {
width: 100%;
padding: 2px 8px;
box-sizing: border-box;
background: #f8fafc;
margin-bottom: 8px;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
.case-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.case-name {
font-weight: 500;
color: #1e293b;
}
.case-index {
font-size: 11px;
color: #64748b;
background: #e2e8f0;
padding: 2px 6px;
border-radius: 10px;
}
}
.case-value {
color: #64748b;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 2px 0;
}
}
}
}
}
.empty-text {
color: #999;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div>
<div class="output-params">
<div class="input-params">
<div v-for="(param, index) in inputParams" :key="index" class="param-item">
<svg-icon :size="24" class="param-icon" name="local-var" />
<span class="param-name">{{ param.name }}</span>
</div>
</div>
<!-- 显示文本内容预览 -->
<div v-if="node.textParams && node.textParams.content" class="text-preview">
<div class="text-content">
{{ getPreviewText(node.textParams.content) }}
</div>
</div>
</div>
</div>
</template>
<script>
import common from './common.ts';
export default {
name: 'TextNode',
mixins: [common],
methods: {
// 获取预览文本,限制长度
getPreviewText(content) {
if (!content) return '未设置文本内容';
const maxLength = 50;
return content.length > maxLength ? content.substring(0, maxLength) + '...' : content;
},
},
};
</script>
<style scoped>
/* 添加样式 */
.output-params {
padding: 8px 12px;
}
.param-item {
display: flex;
align-items: center;
margin-bottom: 4px;
padding: 3px 10px;
font-size: 13px;
color: #333;
border-radius: 4px;
font-size: 12px;
background-color: rgb(242, 244, 247);
}
.param-icon {
color: rgb(41 112 255);
font-weight: bold;
}
.param-name {
padding: 2px 5px;
background-color: #fff;
box-sizing: border-box;
margin-right: 4px;
font-size: 10px;
font-weight: bold;
border-radius: 4px;
}
.param-value {
color: #666;
font-weight: 500;
}
.text-preview {
margin-top: 8px;
padding: 6px 8px;
background-color: #f8f9fa;
border-radius: 4px;
border-left: 3px solid #409eff;
}
.text-content {
font-size: 12px;
color: #666;
line-height: 1.4;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,18 @@
import { Node } from '../types/node';
import { PropType } from 'vue';
export default {
inject: ['parent'],
props: {
node: {
type: Object as PropType<Node>,
required: true
}
},
data() {
return {
inputParams: this.node.inputParams || [],
outputParams: this.node.outputParams || []
}
}
}

View File

@@ -0,0 +1,319 @@
import { Node } from '../types/node';
interface NodeType extends Partial<Node> {
type: string;
name: string;
canBeSource: boolean;
canBeTarget: boolean;
panel?: string;
switchParams?: {
code: string;
cases: Array<{
name: string;
value: number;
}>;
};
questionParams?: {
modelConfig: {
model: string;
max_tokens: number;
temperature: number;
top_p: number;
frequency_penalty: number;
presence_penalty: number;
stream: boolean;
};
categories: Array<{
name: string;
value: string;
}>;
};
codeParams?: {
code: string;
};
noticeParams?: {
message: string;
};
httpParams?: {
url: string;
method: string;
contentType: string;
bodyParams: any[];
paramsParams: any[];
headerParams: any[];
};
dbParams?: {
sql: string;
dbId: string;
dbName: string;
};
llmParams?: {
messages: Array<{
role: string;
content: string;
}>;
modelConfig: {
model: string;
isVision: string;
max_tokens: number;
temperature: number;
top_p: number;
frequency_penalty: number;
presence_penalty: number;
stream: boolean;
};
};
mcpParams?: {
mcpId: string;
mcpName: string;
prompt: string;
};
textParams?: {
content: string;
};
}
export const nodeTypes: NodeType[] = [
{
type: 'start',
name: '开始',
canBeSource: true,
canBeTarget: false,
panel: 'StartPanel',
},
{
type: 'switch',
name: '分支节点',
canBeSource: true,
canBeTarget: true,
switchParams: {
code: 'function main(args) {\n // 根据参数选择执行分支0 还是分支1\n return args.arg1 === "xxx" ? 0 : 1;\n}',
cases: [
{
name: '分支1',
value: 0,
},
{
name: '分支2',
value: 1,
}
],
},
outputParams: [
{
name: 'index',
type: 'String',
},
],
},
{
type: 'question',
name: '问题分类',
canBeSource: true,
canBeTarget: true,
questionParams: {
modelConfig: {
model: '',
max_tokens: 4096,
temperature: 0.7,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: true,
},
categories: [
{
name: '分类1',
value: '1',
},
{
name: '分类2',
value: '2',
},
],
},
outputParams: [
{
name: 'index',
type: 'String',
},
],
},
{
type: 'code',
name: '执行代码',
canBeSource: true,
canBeTarget: true,
codeParams: {
code: 'function main(args){\n return ""\n}',
},
outputParams: [
{
name: 'result',
type: 'String',
}
],
},
{
type: 'notice',
name: '消息通知',
canBeSource: true,
canBeTarget: true,
noticeParams: {
message: '',
},
outputParams: [
{
name: 'result',
type: 'Boolean',
},
],
},
{
type: 'http',
name: 'HTTP请求',
canBeSource: true,
canBeTarget: true,
httpParams: {
url: '',
method: '',
contentType: 'application/json',
bodyParams: [],
paramsParams: [],
headerParams: [],
},
outputParams: [
{
name: 'body',
type: 'Object',
},
{
name: 'status_code',
type: 'Number',
},
{
name: 'headers',
type: 'Object',
},
],
},
{
type: 'rag',
name: 'RAG知识库',
canBeSource: true,
canBeTarget: true,
ragParams: {
datasetId: 0,
datasetName: '',
prompt: '${arg1}',
onlyRecall: '1',
},
outputParams: [
{
name: 'result',
type: 'String',
},
{
name: 'ragSearched',
type: 'String',
},
],
},
{
type: 'db',
name: 'DB数据库',
canBeSource: true,
canBeTarget: true,
dbParams: {
sql: '',
dbId: '',
dbName: '',
},
outputParams: [
{
name: 'result',
type: 'String',
},
],
},
{
type: 'llm',
name: 'LLM大模型',
canBeSource: true,
canBeTarget: true,
llmParams: {
messages: [
{
role: 'user',
content: '你是一个问题总结助手。\n任务根据用户提问 ${arg1} 和系统答案 ${arg2},生成一个标准的问题答案。\n要求基于系统答案内容回答回答准确、简洁、有用。',
},
],
modelConfig: {
model: '',
isVision: '0',
max_tokens: 4096,
temperature: 0.7,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: true,
},
},
outputParams: [
{
name: 'content',
type: 'String',
},
],
},
{
type: 'mcp',
name: 'MCP服务',
canBeSource: true,
canBeTarget: true,
mcpParams: {
mcpId: '',
mcpName: '',
prompt: '${arg1}',
},
outputParams: [
{
name: 'result',
type: 'Object',
},
{
name: 'status',
type: 'String',
},
],
},
{
type: 'text',
name: '文本节点',
canBeSource: true,
canBeTarget: true,
textParams: {
content: '',
},
outputParams: [
{
name: 'result',
type: 'String',
},
],
},
{
type: 'end',
name: '结束',
canBeSource: false,
canBeTarget: true,
},
];
import { deepClone } from '/@/utils/other';
export const getNodeConfig = (type: string): NodeType => {
const nodeConfig = nodeTypes.find((node) => node.type === type) || nodeTypes[0];
// 深拷贝节点配置,避免多个节点共享同一个对象引用
return deepClone(nodeConfig) as NodeType;
};

View File

@@ -0,0 +1,103 @@
<template>
<div class="panel-content">
<!-- 输入参数配置 -->
<div class="panel-section mb-2">
<div class="panel-header flex justify-between items-center">
<span>输入变量</span>
<el-button type="primary" size="small" @click="addParam">
<el-icon>
<Plus />
</el-icon>
添加
</el-button>
</div>
<div class="params-list">
<div v-for="(param, index) in inputParams" :key="index" class="mb-4">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="param.name" placeholder="变量名" />
</el-col>
<el-col :span="12">
<el-select v-model="param.type" placeholder="变量值" class="w-full">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeParam(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
<!-- 代码编辑区域 -->
<div class="panel-section mb-2">
<div class="panel-header">
<span>代码编辑</span>
</div>
<code-editor v-model="node.codeParams.code" :json="false" :readonly="false" theme="nord" height="250px" />
</div>
<!-- 输出参数配置 -->
<!-- 输出变量 -->
<div class="panel-section">
<div class="panel-header">
<span>输出变量</span>
</div>
<div class="params-list">
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="10">
<el-text> 变量名 </el-text>
<el-tag>{{ output.name }}</el-tag>
</el-col>
<el-col :span="2">
<el-text>|</el-text>
</el-col>
<el-col :span="11">
<el-text> 变量类型 </el-text>
<el-tag>{{ output.type }}</el-tag>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Plus, Delete } from '@element-plus/icons-vue';
import common from './common.ts';
import './panel.css';
import CodeEditor from '/@/views/knowledge/aiFlow/components/CodeEditor.vue';
export default {
name: 'CodePanel',
components: {
CodeEditor,
Plus,
Delete,
},
mixins: [common],
data() {
return {
code: this.node.code || '',
};
},
};
</script>
<style>
/* 组件特定样式可以在这里添加 */
</style>

View File

@@ -0,0 +1,137 @@
<template>
<div class="panel-content">
<!-- 输入变量部分 -->
<div class="mb-2 panel-section">
<div class="flex justify-between items-center panel-header">
<span>变量输入</span>
<el-button @click="addParam" type="primary" size="small">
<el-icon><Plus /></el-icon>添加
</el-button>
</div>
<div class="params-list">
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="param.name" placeholder="变量名" />
</el-col>
<el-col :span="12">
<el-select v-model="param.type" placeholder="变量值" class="w-full">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeParam(index)">
<el-icon><Delete /></el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
<!-- 数据源选择 -->
<div class="mb-2 panel-section">
<div class="panel-header">
<span>数据源选择</span>
</div>
<el-select v-model="node.dbParams.dbId" class="w-full" placeholder="请选择数据源" @change="handleDbChange">
<el-option value="" label="请选择数据源" />
<el-option v-for="db in dbList" :key="db.id" :value="db.id" :label="db.name" />
</el-select>
</div>
<!-- SQL语句 -->
<div class="mb-2 panel-section">
<div class="panel-header">
<span>SQL语句</span>
</div>
<code-editor v-model="node.dbParams.sql" :json="false" :readonly="false" theme="nord" height="250px" />
</div>
<!-- 输出变量 -->
<div class="panel-section">
<div class="panel-header">
<span>输出变量</span>
</div>
<div class="params-list">
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-text> 变量名 </el-text>
<el-tag>{{ output.name }}</el-tag>
</el-col>
<el-col :span="2">
<el-text>|</el-text>
</el-col>
<el-col :span="11">
<el-text> 变量类型 </el-text>
<el-tag>{{ output.type }}</el-tag>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Plus, Delete } from '@element-plus/icons-vue';
import common from './common.ts';
import './panel.css';
import {list} from "/@/api/gen/datasource";
import CodeEditor from '/@/views/knowledge/aiFlow/components/CodeEditor.vue';
import { ref } from 'vue';
export default {
name: 'DbPanel',
components: {
CodeEditor,
Plus,
Delete,
},
mixins: [common],
data() {
return {
dbList: [
],
};
},
created() {
this.loadDbList();
},
methods: {
async loadDbList() {
const {data} = await list()
this.dbList = data
},
handleDbChange() {
if (!this.node.dbParams.dbId) {
this.node.dbParams.dbName = '';
return;
}
const selectedDb = this.dbList.find((db) => db.id === this.node.dbParams.dbId);
if (selectedDb) {
this.node.dbParams.dbName = selectedDb.name;
}
},
},
setup() {
const result = ref(null);
return {
result
};
},
};
</script>
<style>
/* 组件特定样式可以在这里添加 */
</style>

View File

@@ -0,0 +1,61 @@
<template>
<div class="panel-content">
<!-- 输出变量配置区域 -->
<div class="panel-section">
<div class="panel-header flex justify-between items-center">
<span>输出变量</span>
<el-button v-if="parent.isFlow" type="primary" size="small" @click="addParam">
<el-icon>
<Plus />
</el-icon>
添加输出
</el-button>
</div>
<!-- 输出变量列表 -->
<div class="params-list">
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="param.name" :disabled="!parent.isFlow" placeholder="变量名" />
</el-col>
<el-col :span="12">
<el-select v-model="param.type" placeholder="变量值" class="w-full">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button v-if="parent.isFlow && index > 0" @click="removeParam(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Plus, Delete } from '@element-plus/icons-vue';
import common from './common.ts';
export default {
name: 'EndPanel',
components: {
Plus,
Delete,
},
mixins: [common],
};
</script>
<style>
/* 组件特定样式可以在这里添加 */
</style>

View File

@@ -0,0 +1,252 @@
<template>
<div class="panel-content">
<!-- 输入变量部分 -->
<div class="mb-2 panel-section">
<div class="flex justify-between items-center panel-header">
<span>变量输入</span>
<el-button type="primary" size="small" @click="addParam">
<el-icon>
<Plus />
</el-icon>
添加
</el-button>
</div>
<div class="params-list">
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="param.name" placeholder="变量名" />
</el-col>
<el-col :span="12">
<el-select v-model="param.type" placeholder="变量值" class="w-full">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeHttpParam(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
<!-- 请求方法 -->
<div class="mb-2 panel-section">
<div class="panel-header">
<span>请求方法</span>
</div>
<div class="param-item">
<el-row :gutter="12">
<el-col :span="6">
<el-select v-model="node.httpParams.method" class="w-full">
<el-option value="GET" label="GET" />
<el-option value="POST" label="POST" />
<el-option value="PUT" label="PUT" />
<el-option value="DELETE" label="DELETE" />
</el-select>
</el-col>
<el-col :span="18">
<el-input v-model="node.httpParams.url" placeholder="请求URL" />
</el-col>
</el-row>
</div>
</div>
<!-- 请求参数 -->
<div class="mb-2 panel-section">
<div class="flex justify-between items-center panel-header">
<span>请求参数</span>
<el-button type="primary" size="small" @click="addHttpParam">
<el-icon>
<Plus />
</el-icon>
添加
</el-button>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane label="Params" name="params">
<div class="params-list">
<div v-for="(param, index) in node.httpParams.paramsParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="param.name" placeholder="参数名称" />
</el-col>
<el-col :span="12">
<el-select v-model="param.type" placeholder="变量值" class="w-full">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeHttpParam(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="Body" name="body" v-if="['POST', 'PUT'].includes(node.httpParams.method)">
<el-radio-group v-model="node.httpParams.contentType" size="small" class="mb-2">
<el-radio label="none"></el-radio>
<el-radio label="application/x-www-form-urlencoded">Form Data</el-radio>
<el-radio label="application/json">JSON</el-radio>
</el-radio-group>
<template v-if="node.httpParams.contentType === 'application/x-www-form-urlencoded'">
<div class="params-list">
<div v-for="(param, index) in node.httpParams.bodyParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="param.name" placeholder="参数名称" />
</el-col>
<el-col :span="12">
<el-select v-model="param.type" placeholder="变量值" class="w-full">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeBody(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</template>
<template v-else-if="node.httpParams.contentType === 'application/json'">
<el-row>
<json-editor v-model="node.httpParams.jsonBody" />
</el-row>
</template>
</el-tab-pane>
<el-tab-pane label="Headers" name="headers">
<div class="params-list">
<div v-for="(header, index) in node.httpParams.headerParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="header.name" placeholder="Header名称" />
</el-col>
<el-col :span="12">
<el-select v-model="header.type" placeholder="变量值" class="w-full">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeHeader(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 输出变量 -->
<div class="panel-section">
<div class="panel-header">
<span>输出变量</span>
</div>
<div class="params-list">
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-text> 变量名 </el-text>
<el-tag>{{ output.name }}</el-tag>
</el-col>
<el-col :span="2">
<el-text>|</el-text>
</el-col>
<el-col :span="11">
<el-text> 变量类型 </el-text>
<el-tag>{{ output.type }}</el-tag>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Plus, Delete } from '@element-plus/icons-vue';
import common from './common.ts';
// @ts-ignore
import JsonEditor from '@axolo/json-editor-vue';
export default {
name: 'HttpPanel',
components: {
JsonEditor,
Plus,
Delete,
},
mixins: [common],
data() {
return {
activeTab: 'params', // 默认显示Params标签页
};
},
methods: {
addHttpParam() {
if (this.activeTab === 'params') {
this.node.httpParams.paramsParams.push({});
} else if (this.activeTab === 'headers') {
this.node.httpParams.headerParams.push({});
} else {
this.node.httpParams.bodyParams.push({});
}
},
removeHttpParam(index) {
this.node.httpParams.paramsParams.splice(index, 1);
},
addHeader() {
this.node.httpParams.headerParams.push({});
},
removeHeader(index) {
this.node.httpParams.headerParams.splice(index, 1);
},
addBody() {
this.node.httpParams.bodyParams.push({});
},
removeBody(index) {
this.node.httpParams.bodyParams.splice(index, 1);
},
},
};
</script>

View File

@@ -0,0 +1,222 @@
<template>
<div class="panel-content">
<!-- 输入变量部分 -->
<div class="mb-2 panel-section">
<div class="flex justify-between items-center panel-header">
<span>变量输入</span>
<el-button type="primary" size="small" @click="addParam">
<el-icon>
<Plus />
</el-icon>
添加
</el-button>
</div>
<div class="params-list">
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="param.name" placeholder="变量名" />
</el-col>
<el-col :span="12">
<el-select v-model="param.type" placeholder="变量值" class="w-full">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeParam(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
<!-- 消息列表部分 -->
<div class="mb-2 panel-section">
<div class="flex justify-between items-center panel-header">
<span>对话消息</span>
<el-button type="primary" size="small" @click="addMessage">
<el-icon>
<Plus />
</el-icon>
添加
</el-button>
</div>
<div class="param-item">
<el-draggable v-model="messages" :animation="200" item-key="index" handle=".drag-handle" class="w-full">
<template #item="{ element: message, index }">
<div class="mb-2">
<div style="display: flex; align-items: center; margin-bottom: 5px">
<el-icon class="cursor-move drag-handle">
<Rank />
</el-icon>
<el-select v-model="message.role" class="w-full">
<el-option v-for="option in roleOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-button v-if="index !== 0" @click="removeMessage(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</div>
<el-input v-model="message.content" type="textarea" :rows="3" placeholder="使用${变量名}格式引用上方定义的变量" />
</div>
</template>
</el-draggable>
</div>
</div>
<!-- 模型参数配置 -->
<div class="mb-2 panel-section">
<div class="panel-header">
<span>模型配置</span>
</div>
<el-form label-position="top">
<div class="param-item param-item-margin">
<div class="flex items-center">
<span class="mr-2">多模态</span>
<el-radio-group v-model="modelConfig.isVision" @change="onVisionChange">
<el-radio label="0"></el-radio>
<el-radio label="1"></el-radio>
</el-radio-group>
</div>
</div>
<div class="param-item param-item-margin">
<div class="flex items-center">
<span class="mr-2">大模型</span>
<model-select
v-model="modelConfig.model"
:key="modelSelectKey"
:type="modelConfig.isVision === '1' ? ['Vision'] : undefined"
class="flex-1"
/>
</div>
</div>
<template v-if="modelConfig.isVision === '1'">
<div class="param-item param-item-margin">
<div class="flex items-center">
<span class="mr-2">图片来源</span>
<el-select v-model="modelConfig.picUrl" placeholder="请选择图片变量" class="flex-1">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</div>
</div>
</template>
</el-form>
</div>
<!-- 输出变量部分 -->
<div class="panel-section">
<div class="panel-header">
<span>输出变量</span>
</div>
<div class="params-list">
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-text> 变量名 </el-text>
<el-tag>{{ output.name }}</el-tag>
</el-col>
<el-col :span="2">
<el-text>|</el-text>
</el-col>
<el-col :span="11">
<el-text> 变量类型 </el-text>
<el-tag>{{ output.type }}</el-tag>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Plus, Delete, Rank } from '@element-plus/icons-vue';
import common from './common';
import draggable from 'vuedraggable';
import ModelSelect from '../components/ModelSelect.vue';
export default {
name: 'LlmPanel',
mixins: [common],
components: {
Plus,
Delete,
Rank,
'el-draggable': draggable,
ModelSelect,
},
data() {
return {
messages: this.node.llmParams.messages,
modelConfig: this.node.llmParams.modelConfig || {
model: '',
isVision: '0',
max_tokens: 50,
temperature: 0.7,
top_p: 1,
},
modelList: [],
roleOptions: [
{ value: 'USER', label: 'USER' },
{ value: 'SYSTEM', label: 'SYSTEM' },
{ value: 'AI', label: 'ASSISTANT' },
],
modelSelectKey: 0,
};
},
watch: {
'modelConfig.picUrl': function (val) {
this.node.llmParams.modelConfig.picUrl = val;
},
modelConfig: {
deep: true,
handler(val) {
this.node.llmParams.modelConfig = val;
},
},
},
methods: {
addMessage() {
this.messages.push({
role: 'USER',
content: '',
});
},
removeMessage(index) {
if (this.messages.length > 1) {
this.messages.splice(index, 1);
}
},
onVisionChange() {
this.modelConfig.model = '';
this.modelConfig.picUrl = '';
this.modelSelectKey++;
},
},
};
</script>
<style scoped>
.cursor-move {
margin-right: 10px;
}
.w-full {
margin-right: 15px;
}
</style>

View File

@@ -0,0 +1,183 @@
<template>
<div class="panel-content">
<!-- 输入变量部分 -->
<div class="mb-2 panel-section">
<div class="flex justify-between items-center panel-header">
<span>变量输入</span>
<el-button type="primary" size="small" @click="addParam">
<el-icon>
<Plus />
</el-icon>
添加
</el-button>
</div>
<div class="params-list">
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="param.name" placeholder="变量名" />
</el-col>
<el-col :span="12">
<el-select v-model="param.type" placeholder="变量值" class="w-full">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeParam(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
<!-- MCP 配置部分 -->
<div class="mb-2 panel-section">
<div class="panel-header">
<span>MCP 配置</span>
</div>
<el-form label-position="top">
<div class="param-item param-item-margin">
<div class="flex items-center">
<span class="mr-2">MCP 服务</span>
<el-select v-model="node.mcpParams.mcpId" placeholder="请选择 MCP 服务" class="flex-1" @change="onMcpChange">
<el-option v-for="mcp in mcpList" :key="mcp.mcpId" :label="mcp.name" :value="mcp.mcpId">
<span style="float: left">{{ mcp.name }}</span>
<span style="float: right; color: #8492a6; font-size: 13px">{{ mcp.mcpType }}</span>
</el-option>
</el-select>
</div>
</div>
<div class="param-item param-item-margin" v-if="node.mcpParams.mcpId">
<div class="flex items-center">
<span class="mr-2">请求提示词</span>
<el-input
v-model="node.mcpParams.prompt"
type="textarea"
:rows="3"
placeholder="请求提示词,使用${变量名}引用上方定义的变量"
class="flex-1"
/>
</div>
</div>
</el-form>
</div>
<!-- 输出变量部分 -->
<div class="panel-section">
<div class="flex justify-between items-center panel-header">
<span>输出变量</span>
<el-button type="primary" size="small" @click="addOutput">
<el-icon>
<Plus />
</el-icon>
添加
</el-button>
</div>
<div class="params-list">
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="output.name" placeholder="变量名" />
</el-col>
<el-col :span="12">
<el-select v-model="output.type" placeholder="变量类型" class="w-full">
<el-option label="文本" value="string" />
<el-option label="数字" value="number" />
<el-option label="布尔值" value="boolean" />
<el-option label="对象" value="object" />
<el-option label="数组" value="array" />
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeOutput(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Plus, Delete } from '@element-plus/icons-vue';
import common from './common';
import { list } from '/@/api/knowledge/aiMcpConfig';
export default {
name: 'McpPanel',
mixins: [common],
components: {
Plus,
Delete,
},
data() {
return {
mcpList: [],
};
},
computed: {
selectedMcp() {
return this.mcpList.find(mcp => mcp.mcpId === this.node.mcpParams?.mcpId);
},
},
async mounted() {
// 确保 mcpParams 对象存在
if (!this.node.mcpParams) {
this.$set(this.node, 'mcpParams', {
mcpId: '',
mcpName: '',
prompt: '',
});
}
await this.fetchMcpList();
// 确保输出参数有默认值
if (!this.outputParams.length) {
this.outputParams.push({
name: 'result',
type: 'string',
});
}
},
methods: {
async fetchMcpList() {
try {
const { data } = await list();
this.mcpList = data || [];
} catch (error) {
console.error('获取 MCP 列表失败:', error);
}
},
onMcpChange() {
const selectedMcp = this.selectedMcp;
if (selectedMcp) {
this.node.mcpParams.mcpName = selectedMcp.name;
}
},
},
};
</script>
<style scoped>
.w-full {
margin-right: 15px;
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="panel-content">
<!-- 输入变量部分 -->
<div class="mb-2 panel-section">
<div class="flex justify-between items-center panel-header">
<span>变量输入</span>
<el-button type="primary" size="small" @click="addParam">
<el-icon>
<Plus />
</el-icon>
添加
</el-button>
</div>
<div class="params-list">
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="param.name" placeholder="变量名" />
</el-col>
<el-col :span="12">
<el-select v-model="param.type" placeholder="变量值">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeParam(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
<!-- 消息模板 -->
<div class="mb-2 panel-section">
<div class="panel-header">
<span>消息模板</span>
</div>
<div class="mb-2 template-select">
<el-select v-model="node.noticeParams.channelId" placeholder="请选择渠道" style="width: 200px" @change="handleChannelChange">
<el-option v-for="channel in channelList" :key="channel.value" :value="channel.value" :label="channel.label" />
</el-select>
<el-select
v-model="node.noticeParams.templateId"
placeholder="请选择模板"
style="width: 400px"
:disabled="!node.noticeParams.channelId"
@change="handleTemplateChange"
>
<el-option v-for="template in templateList" :key="template.configKey" :value="template.configKey" :label="template.configName" />
</el-select>
</div>
</div>
<!-- 消息内容 -->
<div class="mb-2 panel-section">
<div class="panel-header">
<span>消息内容</span>
</div>
<code-editor v-model="node.noticeParams.templateCode" :json="false" :readonly="false" theme="nord" height="250px" />
</div>
</div>
</template>
<script>
import { Plus, Delete } from '@element-plus/icons-vue';
import common from './common';
import './panel.css';
import { list } from '/@/api/admin/message';
import CodeEditor from '/@/views/knowledge/aiFlow/components/CodeEditor.vue';
export default {
name: 'NoticePanel',
components: {
Plus,
Delete,
CodeEditor,
},
mixins: [common],
data() {
return {
channelList: [
{
value: 'webhook',
label: 'HOOK',
},
{
value: 'sms',
label: '短信',
},
{
value: 'email',
label: '邮件',
},
], // 渠道列表
templateList: [], // 模板列表
};
},
created() {},
methods: {
// 渠道变更处理
async handleChannelChange() {
if (this.node.noticeParams.channelId) {
const { data } = await list({ messageType: this.node.noticeParams.channelId });
this.templateList = data || [];
}
},
// 模板变更处理
handleTemplateChange() {
// 存储模板ID和模板代码
if (this.currentTemplate) {
this.node.noticeParams.templateId = this.currentTemplate.configKey;
this.node.noticeParams.templateCode = this.currentTemplate.templateCode;
} else {
this.node.noticeParams.templateCode = '';
}
},
},
computed: {
// 获取当前选中的模板对象
currentTemplate() {
return this.templateList.find((template) => template.configKey === this.node.noticeParams.templateId) || {};
},
},
};
</script>
<style scoped>
.template-select {
display: flex;
gap: 10px;
}
.template-select .form-select {
flex: 1;
}
.template-params {
margin-bottom: 8px;
padding: 8px;
background-color: #f5f7fa;
border-radius: 4px;
}
.template-param-title {
font-size: 12px;
color: #606266;
margin-bottom: 4px;
}
.template-param-content {
font-size: 14px;
color: #303133;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,210 @@
<template>
<div class="panel-content">
<!-- 输入参数配置 -->
<div class="mb-2 panel-section">
<div class="flex justify-between items-center panel-header">
<span>输入变量</span>
<el-button type="primary" size="small" @click="addParam">
<el-icon>
<Plus />
</el-icon>
添加
</el-button>
</div>
<div class="params-list">
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="param.name" placeholder="变量名" />
</el-col>
<el-col :span="12">
<el-select v-model="param.type" placeholder="变量值" class="w-full">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeParam(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
<!-- 分类列表配置 -->
<div class="mb-4 panel-section">
<div class="flex justify-between items-center panel-header">
<span>问题分类</span>
<el-button type="primary" size="small" @click="addClass">
<el-icon>
<Plus />
</el-icon>
添加
</el-button>
</div>
<div class="params-list">
<div v-for="(item, index) in node.questionParams?.categories" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="item.name" placeholder="分类名称" disabled>
<template #prepend>分类</template>
</el-input>
</el-col>
<el-col :span="12">
<el-input v-model="item.value" placeholder="请输入主题内容" />
</el-col>
<el-col :span="3">
<el-button @click="removeClass(index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
<!-- 模型参数配置 -->
<div class="mb-4 panel-section">
<div class="panel-header">
<span>模型配置</span>
</div>
<el-form label-position="top">
<div class="param-item param-item-margin">
<div class="flex items-center">
<span class="mr-2">调度模型</span>
<model-select v-model="modelConfig.model" :type="['Chat']" class="flex-1" />
</div>
</div>
<div class="param-item param-item-margin">
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="最大 Tokens">
<el-input-number v-model="modelConfig.max_tokens" :min="1" :max="100" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Temperature">
<el-input-number v-model="modelConfig.temperature" :min="0" :max="2" :step="0.1" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Top P">
<el-input-number v-model="modelConfig.top_p" :min="0" :max="1" :step="0.1" class="w-full" />
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
</div>
<!-- 输出变量展示 -->
<div class="panel-section">
<div class="panel-header">
<span>输出变量</span>
</div>
<div class="params-list">
<div v-for="(output, index) in outputParams" :key="index" class="mb-4">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-text> 变量名 </el-text>
<el-tag>{{ output.name }}</el-tag>
</el-col>
<el-col :span="2">
<el-text>|</el-text>
</el-col>
<el-col :span="11">
<el-text> 变量类型 </el-text>
<el-tag>{{ output.type }}</el-tag>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Plus, Delete } from '@element-plus/icons-vue';
import { reactive } from 'vue';
import common from './common.ts';
import './panel.css';
import ModelSelect from '../components/ModelSelect.vue';
export default {
name: 'QuestionPanel',
inject: ['parent'],
components: {
Plus,
Delete,
ModelSelect,
},
mixins: [common],
props: {
node: {
type: Object,
required: true,
},
},
setup(props) {
const modelConfig = reactive(
props.node.questionParams.modelConfig || {
model: '',
max_tokens: 50,
temperature: 0.7,
top_p: 1,
}
);
if (!props.node.questionParams.modelConfig) {
props.node.questionParams.modelConfig = modelConfig;
}
return {
modelConfig,
};
},
methods: {
addClass() {
if (!this.node.questionParams?.categories) {
this.node.questionParams.categories = [];
}
this.node.questionParams.categories.push({
name: `${this.node.questionParams.categories.length + 1}`,
value: '',
});
this.$nextTick(() => {
this.parent.updateNodeConnections(this.node);
});
},
removeClass(index) {
this.node.questionParams.categories.splice(index, 1);
this.$nextTick(() => {
this.parent.updateNodeConnections(this.node, index);
});
},
},
};
</script>
<style>
/* 组件特定样式可以在这里添加 */
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div class="panel-content">
<!-- 输入变量部分 -->
<div class="mb-2 panel-section">
<div class="flex justify-between items-center panel-header">
<span>变量输入</span>
<el-button @click="addParam" type="primary" size="small">
<el-icon><Plus /></el-icon>添加
</el-button>
</div>
<div class="params-list">
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="9">
<el-input v-model="param.name" placeholder="变量名" />
</el-col>
<el-col :span="12">
<el-select v-model="param.type" placeholder="变量值" class="w-full">
<el-option-group v-for="item in previousOutputParams" :key="item.name" :label="item.name">
<el-option v-for="param in item.list" :key="param.name" :label="param.name" :value="`${item.id}.${param.name}`" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="3">
<el-button @click="removeParam(index)">
<el-icon><Delete /></el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
<!-- 知识库选择 -->
<div class="mb-2 panel-section">
<div class="panel-header">
<span>知识库选择</span>
</div>
<el-select v-model="node.ragParams.datasetId" class="w-full" placeholder="请选择知识库" @change="handleDatasetChange">
<el-option value="" label="请选择知识库" />
<el-option v-for="dataset in datasetList" :key="dataset.id" :value="dataset.id" :label="dataset.name" />
</el-select>
</div>
<div class="mb-2 panel-section">
<div class="panel-header">仅召回<tip content="如果设置为【是】,则只查询向量" /></div>
<el-radio-group v-model="node.ragParams.onlyRecall">
<el-radio border label="0"></el-radio>
<el-radio border label="1"></el-radio>
</el-radio-group>
</div>
<div class="mb-2 panel-section">
<div class="panel-header">
<span>提示词</span>
</div>
<code-editor v-model="node.ragParams.prompt" :json="false" :readonly="false" theme="nord" height="250px" />
</div>
<!-- 输出变量 -->
<div class="panel-section">
<div class="panel-header">
<span>输出变量</span>
</div>
<div class="params-list">
<div v-for="(output, index) in outputParams" :key="index" class="mb-2">
<div class="param-item">
<el-row :gutter="12">
<el-col :span="10">
<el-text> 变量名 </el-text>
<el-tag>{{ output.name }}</el-tag>
</el-col>
<el-col :span="2">
<el-text>|</el-text>
</el-col>
<el-col :span="11">
<el-text> 变量类型 </el-text>
<el-tag>{{ output.type }}</el-tag>
</el-col>
</el-row>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Plus, Delete } from '@element-plus/icons-vue';
import common from './common.ts';
import './panel.css';
import { fetchDataList } from '/@/api/knowledge/aiDataset';
import CodeEditor from '/@/views/knowledge/aiFlow/components/CodeEditor.vue';
import { ref } from 'vue';
export default {
name: 'RagPanel',
components: {
CodeEditor,
Plus,
Delete,
},
mixins: [common],
data() {
return {
datasetList: [],
};
},
created() {
this.loadDbList();
},
methods: {
async loadDbList() {
const { data } = await fetchDataList();
this.datasetList = data;
},
handleDatasetChange() {
if (!this.node.ragParams.datasetId) {
this.node.ragParams.datasetId = '';
return;
}
const selectedDb = this.datasetList.find((dataset) => dataset.id === this.node.ragParams.datasetId);
if (selectedDb) {
this.node.ragParams.datasetId = selectedDb.id;
this.node.ragParams.datasetName = selectedDb.name;
}
},
},
setup() {
const result = ref(null);
return {
result,
};
},
};
</script>
<style>
/* 组件特定样式可以在这里添加 */
</style>

View File

@@ -0,0 +1,304 @@
<template>
<div class="panel-content">
<!-- 变量列表区域 -->
<div class="panel-section">
<div class="flex justify-between items-center panel-header">
<span>变量列表</span>
<el-button type="primary" size="small" @click="addOutput">
<el-icon>
<Plus/>
</el-icon>
添加
</el-button>
</div>
<div class="params-list">
<div v-for="(param, index) in inputParams" :key="index" class="mb-2">
<div class="param-item">
<div class="flex justify-between items-center">
<div>
<div>
<el-tag type="primary" size="small">
{{ getInputTypeLabel(param.inputType) }}
</el-tag>
<el-text>
</el-text>
<el-tag :type="param.required ? 'danger' : 'info'" size="small">
{{ param.required ? '必填' : '选填' }}
</el-tag>
<el-text>
</el-text>
<el-text>
{{ param.name }} ({{ param.type }})
</el-text>
</div>
</div>
<div v-if="!param.disabled" class="flex gap-2">
<el-button type="primary" size="small" @click="editParam(index)">
<el-icon>
<Edit/>
</el-icon>
</el-button>
<el-button size="small" @click="removeOutput(index)">
<el-icon>
<Delete/>
</el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 变量编辑对话框 -->
<el-dialog
v-model="editDialogVisible"
:title="isEdit ? '编辑变量' : '添加变量'"
width="600px"
destroy-on-close
>
<el-form
ref="paramForm"
:model="editingParam"
:rules="rules"
label-position="top"
>
<el-form-item label="显示名称" prop="name">
<el-input v-model="editingParam.name" placeholder="请输入显示名称"/>
</el-form-item>
<el-form-item label="变量名" prop="type">
<el-input v-model="editingParam.type" placeholder="请输入变量名"/>
</el-form-item>
<el-form-item label="输入类型" prop="inputType">
<el-select
v-model="editingParam.inputType"
class="w-full"
@change="handleEditInputTypeChange"
>
<el-option
v-for="item in inputTypeDict"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="是否必填" prop="required">
<el-select v-model="editingParam.required" class="w-full">
<el-option :value="false" label="否"/>
<el-option :value="true" label="是"/>
</el-select>
</el-form-item>
<!-- 选项编辑表单 -->
<template v-if="editingParam.inputType==='select'">
<el-divider content-position="left">选项配置</el-divider>
<div class="options-list">
<div v-for="(option, index) in editingParam.editingOptions" :key="index" class="mb-2">
<el-row :gutter="12">
<el-col :span="9">
<el-form-item
:prop="'editingOptions.' + index + '.label'"
:rules="{ required: true, message: '请输入选项名称', trigger: 'blur' }"
>
<el-input v-model="option.label" placeholder="请输入选项名称"/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item
:prop="'editingOptions.' + index + '.value'"
:rules="{ required: true, message: '请输入选项值', trigger: 'blur' }"
>
<el-input v-model="option.value" placeholder="请输入选项值"/>
</el-form-item>
</el-col>
<el-col :span="3">
<el-button @click="removeOption(index)">
<el-icon>
<Delete/>
</el-icon>
</el-button>
</el-col>
</el-row>
</div>
<el-button type="primary" @click="addOption">
<el-icon>
<Plus/>
</el-icon>
添加选项
</el-button>
</div>
</template>
</el-form>
<template #footer>
<div class="flex gap-2 justify-end">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script>
import {Plus, Delete, Edit} from '@element-plus/icons-vue'
import common from './common.ts'
export default {
name: 'StartPanel',
components: {
Plus,
Delete,
Edit
},
mixins: [common],
data() {
return {
inputTypeDict: [
{label: '输入框', value: 'input'},
{label: '下拉框', value: 'select'},
{label: '数字框', value: 'number'},
{label: '文本框', value: 'textarea'},
{label: '图片', value: 'image'},
],
editDialogVisible: false,
editingParam: {
name: '',
type: '',
value: '',
required: false,
inputType: 'input',
options: [],
editingOptions: []
},
isEdit: false,
rules: {
name: [
{required: true, message: '请输入显示名称', trigger: 'blur'},
{min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur'}
],
type: [
{required: true, message: '请输入变量名', trigger: 'blur'},
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: '变量名只能包含字母、数字和下划线,且必须以字母开头',
trigger: 'blur'
}
],
inputType: [
{required: true, message: '请选择输入类型', trigger: 'change'}
],
required: [
{required: true, message: '请选择是否必填', trigger: 'change'}
]
}
}
},
mounted() {
this.updateOutputParams()
},
methods: {
addOutput() {
this.isEdit = false
this.editingParam = {
name: '',
type: '',
value: '',
required: false,
inputType: 'input',
options: [],
editingOptions: []
}
this.editDialogVisible = true
},
editParam(index) {
this.isEdit = true
this.editingParamIndex = index
const param = {...this.inputParams[index]}
param.editingOptions = [...(param.options || [])].map(opt => ({...opt}))
this.editingParam = param
this.editDialogVisible = true
},
saveParam() {
if (this.isEdit) {
this.inputParams[this.editingParamIndex] = {...this.editingParam}
} else {
this.inputParams.push({...this.editingParam})
}
this.editDialogVisible = false
},
handleEditInputTypeChange() {
if (this.editingParam.inputType === 'select' && (!this.editingParam.options || this.editingParam.options.length === 0)) {
this.editingParam.options = []
}
},
removeOutput(index) {
this.inputParams.splice(index, 1)
},
updateOutputParams() {
const outputParams = this.inputParams.map(param => ({
name: param.type || '',
type: param.type || '',
value: '',
required: param.required || false,
inputType: param.inputType || 'input',
options: param.options || []
}))
if (this.node) {
this.node.outputParams = outputParams
}
},
handleSave() {
this.$refs.paramForm.validate(async (valid) => {
if (valid) {
if (this.editingParam.inputType === 'select') {
this.editingParam.options = [...this.editingParam.editingOptions]
}
if (this.isEdit) {
this.inputParams[this.editingParamIndex] = {...this.editingParam}
} else {
this.inputParams.push({...this.editingParam})
}
this.editDialogVisible = false
} else {
return false
}
})
},
addOption() {
this.editingParam.editingOptions.push({
label: '',
value: ''
})
},
removeOption(index) {
this.editingParam.editingOptions.splice(index, 1)
},
getInputTypeLabel(type) {
const found = this.inputTypeDict.find(item => item.value === type)
return found ? found.label : '输入框'
}
},
watch: {
inputParams: {
handler() {
this.updateOutputParams()
},
deep: true
}
}
}
</script>
<style>
/* 组件特定样式可以在这里添加 */
</style>

Some files were not shown because too many files have changed in this diff Show More