mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-22 21:06:32 +00:00
feat(ai): 新增智能助手功能模块
feat(backend): 添加ChromaDB向量数据库支持 feat(backend): 实现智能体配置、知识库和文档管理 feat(backend): 重构WebSocket聊天服务为AgentService feat(frontend): 实现完整的聊天界面组件 feat(frontend): 添加智能体配置、知识库和文档管理页面 fix(user): 修复用户导入时性别转换问题 style(import): 优化导入组件加载状态处理
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
const API_PATH = "/application/ai";
|
||||
|
||||
const AiAPI = {
|
||||
listAgentConfig(query?: AgentConfigPageQuery) {
|
||||
return request<ApiResponse<PageResult<AgentConfigTable[]>>>({
|
||||
url: `${API_PATH}/agent-config/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
detailAgentConfig(id: number) {
|
||||
return request<ApiResponse<AgentConfigTable>>({
|
||||
url: `${API_PATH}/agent-config/detail/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
defaultAgentConfig() {
|
||||
return request<ApiResponse<AgentConfigTable>>({
|
||||
url: `${API_PATH}/agent-config/default`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
createAgentConfig(body: AgentConfigForm) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/agent-config/create`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
updateAgentConfig(id: number, body: AgentConfigForm) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/agent-config/update/${id}`,
|
||||
method: "put",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
deleteAgentConfig(body: number[]) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/agent-config/delete`,
|
||||
method: "delete",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
listKnowledge(query?: KnowledgePageQuery) {
|
||||
return request<ApiResponse<PageResult<KnowledgeTable[]>>>({
|
||||
url: `${API_PATH}/knowledge/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
detailKnowledge(id: number) {
|
||||
return request<ApiResponse<KnowledgeTable>>({
|
||||
url: `${API_PATH}/knowledge/detail/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
createKnowledge(body: KnowledgeForm) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/knowledge/create`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
updateKnowledge(id: number, body: KnowledgeForm) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/knowledge/update/${id}`,
|
||||
method: "put",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
deleteKnowledge(body: number[]) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/knowledge/delete`,
|
||||
method: "delete",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
listDocument(query?: DocumentPageQuery) {
|
||||
return request<ApiResponse<PageResult<DocumentTable[]>>>({
|
||||
url: `${API_PATH}/document/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
detailDocument(id: number) {
|
||||
return request<ApiResponse<DocumentTable>>({
|
||||
url: `${API_PATH}/document/detail/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
createDocument(body: DocumentForm) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/document/create`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
updateDocument(id: number, body: DocumentForm) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/document/update/${id}`,
|
||||
method: "put",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
deleteDocument(body: number[]) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/document/delete`,
|
||||
method: "delete",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default AiAPI;
|
||||
|
||||
export interface AgentConfigPageQuery extends PageQuery {
|
||||
name?: string;
|
||||
provider?: string;
|
||||
is_default?: boolean;
|
||||
is_active?: boolean;
|
||||
created_time?: string[];
|
||||
updated_time?: string[];
|
||||
created_id?: number;
|
||||
updated_id?: number;
|
||||
}
|
||||
|
||||
export interface AgentConfigTable extends BaseType {
|
||||
name: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
api_key: string;
|
||||
base_url?: string;
|
||||
temperature: number;
|
||||
system_prompt: string;
|
||||
is_default?: boolean;
|
||||
is_active?: boolean;
|
||||
created_by?: CommonType;
|
||||
updated_by?: CommonType;
|
||||
}
|
||||
|
||||
export interface AgentConfigForm extends BaseFormType {
|
||||
name?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
api_key?: string;
|
||||
base_url?: string;
|
||||
temperature?: number;
|
||||
system_prompt?: string;
|
||||
is_default?: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface KnowledgePageQuery extends PageQuery {
|
||||
name?: string;
|
||||
is_active?: boolean;
|
||||
created_time?: string[];
|
||||
updated_time?: string[];
|
||||
created_id?: number;
|
||||
updated_id?: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeTable extends BaseType {
|
||||
name: string;
|
||||
description?: string;
|
||||
embedding_model: string;
|
||||
chunk_size: number;
|
||||
chunk_overlap: number;
|
||||
is_active?: boolean;
|
||||
created_by?: CommonType;
|
||||
updated_by?: CommonType;
|
||||
}
|
||||
|
||||
export interface KnowledgeForm extends BaseFormType {
|
||||
name?: string;
|
||||
description?: string;
|
||||
embedding_model?: string;
|
||||
chunk_size?: number;
|
||||
chunk_overlap?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface DocumentPageQuery extends PageQuery {
|
||||
knowledge_id?: number;
|
||||
title?: string;
|
||||
file_type?: string;
|
||||
is_indexed?: boolean;
|
||||
created_time?: string[];
|
||||
created_id?: number;
|
||||
}
|
||||
|
||||
export interface DocumentTable extends BaseType {
|
||||
knowledge_id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
file_type: string;
|
||||
file_path?: string;
|
||||
metadata?: Record<string, string>;
|
||||
chunk_count: number;
|
||||
is_indexed: boolean;
|
||||
created_by?: CommonType;
|
||||
updated_by?: CommonType;
|
||||
}
|
||||
|
||||
export interface DocumentForm extends BaseFormType {
|
||||
knowledge_id?: number;
|
||||
title?: string;
|
||||
content?: string;
|
||||
file_type?: string;
|
||||
file_path?: string;
|
||||
metadata?: Record<string, string>;
|
||||
chunk_count?: number;
|
||||
is_indexed?: boolean;
|
||||
}
|
||||
@@ -61,10 +61,10 @@
|
||||
<el-button @click="handleClose">{{ props.cancelButtonText || "取 消" }}</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="importFormData.files.length === 0 || loading"
|
||||
:disabled="importFormData.files.length === 0 || props.loading"
|
||||
:loading="props.loading"
|
||||
@click="handleUpload"
|
||||
>
|
||||
<el-icon v-if="loading"><Loading /></el-icon>
|
||||
{{ props.confirmButtonText || "确 定" }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -161,6 +161,11 @@ interface ImportModalProps {
|
||||
* 导入配置
|
||||
*/
|
||||
contentConfig: IContentConfig;
|
||||
|
||||
/**
|
||||
* 上传loading状态
|
||||
*/
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// 定义props
|
||||
@@ -201,7 +206,6 @@ const emit = defineEmits<{
|
||||
// 引用
|
||||
const importFormRef = ref(null);
|
||||
const uploadRef = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
// 表单数据
|
||||
const importFormData = reactive<{
|
||||
@@ -270,24 +274,19 @@ const handleUpload = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const file = importFormData.files[0].raw as File;
|
||||
const formData = new FormData();
|
||||
formData.append(props.uploadFileName, file);
|
||||
|
||||
// 添加额外参数
|
||||
Object.keys(props.uploadData).forEach((key) => {
|
||||
formData.append(key, props.uploadData[key]);
|
||||
});
|
||||
|
||||
// 触发上传事件,由父组件处理具体上传逻辑
|
||||
emit("upload", formData, file);
|
||||
} catch (error: any) {
|
||||
console.error("上传失败:", error);
|
||||
ElMessage.error("上传失败:" + error.message || error);
|
||||
emit("import-fail", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface UseAiActionOptions {
|
||||
*/
|
||||
export function useAiAction(options: UseAiActionOptions = {}) {
|
||||
const route = useRoute();
|
||||
const { actionHandlers = {}, onRefresh, onAutoSearch, currentRoute = route.path } = options;
|
||||
const { actionHandlers = {}, onRefresh, onAutoSearch } = options;
|
||||
|
||||
// 用于跟踪是否已卸载,防止在卸载后执行回调
|
||||
let isUnmounted = false;
|
||||
@@ -150,12 +150,7 @@ export function useAiAction(options: UseAiActionOptions = {}) {
|
||||
confirmMessage?: string;
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
originalCommand = "",
|
||||
confirmMode = "manual",
|
||||
needConfirm = false,
|
||||
confirmMessage,
|
||||
} = options;
|
||||
const { originalCommand = "", needConfirm = false, confirmMessage } = options;
|
||||
|
||||
// 如果需要确认,先显示确认对话框
|
||||
if (needConfirm && confirmMessage) {
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="search-container">
|
||||
<el-form
|
||||
ref="queryFormRef"
|
||||
:model="queryFormData"
|
||||
:inline="true"
|
||||
label-suffix=":"
|
||||
@submit.prevent="handleQuery"
|
||||
>
|
||||
<el-form-item prop="name" label="智能体名称">
|
||||
<el-input v-model="queryFormData.name" placeholder="请输入智能体名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item prop="provider" label="供应商">
|
||||
<el-select
|
||||
v-model="queryFormData.provider"
|
||||
placeholder="请选择供应商"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
>
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="Deepseek" value="deepseek" />
|
||||
<el-option label="Azure" value="azure" />
|
||||
<el-option label="Anthropic" value="anthropic" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="is_default" label="是否默认">
|
||||
<el-select
|
||||
v-model="queryFormData.is_default"
|
||||
placeholder="请选择"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
>
|
||||
<el-option :value="true" label="是" />
|
||||
<el-option :value="false" label="否" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="is_active" label="状态">
|
||||
<el-select
|
||||
v-model="queryFormData.is_active"
|
||||
placeholder="请选择状态"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
>
|
||||
<el-option :value="true" label="启用" />
|
||||
<el-option :value="false" label="停用" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="search-buttons">
|
||||
<el-button type="primary" icon="search" native-type="submit">查询</el-button>
|
||||
<el-button icon="refresh" @click="handleResetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card class="data-table">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>
|
||||
<el-tooltip content="管理AI智能体配置,包括模型、API Key等配置。">
|
||||
<QuestionFilled class="w-4 h-4 mx-1" />
|
||||
</el-tooltip>
|
||||
智能体配置列表
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="data-table__toolbar">
|
||||
<div class="data-table__toolbar--left">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="1.5">
|
||||
<el-button type="success" icon="plus" @click="handleOpenDialog('create')">
|
||||
新增
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="danger"
|
||||
icon="delete"
|
||||
:disabled="selectIds.length === 0"
|
||||
@click="handleDelete(selectIds)"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="data-table__toolbar--right">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="1.5">
|
||||
<el-tooltip content="刷新">
|
||||
<el-button type="primary" icon="refresh" circle @click="handleRefresh" />
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="dataTableRef"
|
||||
v-loading="loading"
|
||||
:data="pageTableData"
|
||||
highlight-current-row
|
||||
class="data-table__content"
|
||||
border
|
||||
stripe
|
||||
height="390"
|
||||
max-height="390"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column type="index" fixed label="序号" width="60">
|
||||
<template #default="scope">
|
||||
{{ (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="智能体名称" prop="name" min-width="120" />
|
||||
<el-table-column label="供应商" prop="provider" min-width="100">
|
||||
<template #default="scope">
|
||||
<el-tag>{{ scope.row.provider }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模型" prop="model" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="温度参数" prop="temperature" min-width="80" />
|
||||
<el-table-column label="是否默认" prop="is_default" min-width="80">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.is_default ? 'success' : 'info'">
|
||||
{{ scope.row.is_default ? "是" : "否" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="is_active" min-width="80">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.is_active ? 'success' : 'danger'">
|
||||
{{ scope.row.is_active ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" prop="created_time" min-width="180" />
|
||||
<el-table-column fixed="right" label="操作" align="center" min-width="200">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="document"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
icon="delete"
|
||||
@click="handleDelete([scope.row.id])"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<template #footer>
|
||||
<pagination
|
||||
v-model:total="total"
|
||||
v-model:page="queryFormData.page_no"
|
||||
v-model:limit="queryFormData.page_size"
|
||||
@pagination="loadingData"
|
||||
/>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="智能体名称" :span="2">
|
||||
{{ detailFormData.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="供应商">
|
||||
{{ detailFormData.provider }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="模型">
|
||||
{{ detailFormData.model }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="API地址">
|
||||
{{ detailFormData.base_url || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="温度参数">
|
||||
{{ detailFormData.temperature }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="是否默认">
|
||||
<el-tag :type="detailFormData.is_default ? 'success' : 'info'">
|
||||
{{ detailFormData.is_default ? "是" : "否" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="detailFormData.is_active ? 'success' : 'danger'">
|
||||
{{ detailFormData.is_active ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="系统提示词" :span="2">
|
||||
<div class="prompt-content">{{ detailFormData.system_prompt }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">
|
||||
{{ detailFormData.created_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="2">
|
||||
{{ detailFormData.updated_time }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form
|
||||
ref="dataFormRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item prop="name" label="智能体名称">
|
||||
<el-input v-model="formData.name" placeholder="请输入智能体名称" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="provider" label="供应商">
|
||||
<el-select v-model="formData.provider" placeholder="请选择供应商" style="width: 100%">
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="Deepseek" value="deepseek" />
|
||||
<el-option label="Azure" value="azure" />
|
||||
<el-option label="Anthropic" value="anthropic" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="model" label="模型">
|
||||
<el-input v-model="formData.model" placeholder="请输入模型名称,如:gpt-4" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="api_key" label="API Key">
|
||||
<el-input
|
||||
v-model="formData.api_key"
|
||||
type="password"
|
||||
placeholder="请输入API Key"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="base_url" label="API地址">
|
||||
<el-input v-model="formData.base_url" placeholder="请输入自定义API地址(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="temperature" label="温度参数">
|
||||
<el-slider
|
||||
v-model="formData.temperature"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="0.1"
|
||||
:marks="{ 0: '0', 1: '1', 2: '2' }"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="system_prompt" label="系统提示词">
|
||||
<el-input
|
||||
v-model="formData.system_prompt"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="请输入系统提示词"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="is_default" label="是否默认">
|
||||
<el-switch v-model="formData.is_default" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="is_active" label="状态">
|
||||
<el-switch v-model="formData.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { QuestionFilled } from "@element-plus/icons-vue";
|
||||
import AiAPI from "@/api/module_application/ai";
|
||||
import type { AgentConfigTable, AgentConfigForm } from "@/api/module_application/ai";
|
||||
|
||||
const queryFormRef = ref();
|
||||
const dataFormRef = ref();
|
||||
const dataTableRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const pageTableData = ref<AgentConfigTable[]>([]);
|
||||
const total = ref(0);
|
||||
const selectIds = ref<number[]>([]);
|
||||
|
||||
const queryFormData = reactive({
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
name: "",
|
||||
provider: "",
|
||||
is_default: undefined as boolean | undefined,
|
||||
is_active: undefined as boolean | undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
visible: false,
|
||||
title: "",
|
||||
type: "create" as "create" | "update" | "detail",
|
||||
id: 0,
|
||||
});
|
||||
|
||||
const formData = reactive<AgentConfigForm>({
|
||||
name: "",
|
||||
provider: "openai",
|
||||
model: "",
|
||||
api_key: "",
|
||||
base_url: "",
|
||||
temperature: 0.7,
|
||||
system_prompt: "你是一个有用的AI助手,可以帮助用户回答问题和提供帮助。请用中文回答用户的问题。",
|
||||
is_default: false,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const detailFormData = reactive<AgentConfigTable>({
|
||||
name: "",
|
||||
provider: "",
|
||||
model: "",
|
||||
api_key: "",
|
||||
base_url: "",
|
||||
temperature: 0.7,
|
||||
system_prompt: "",
|
||||
is_default: false,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入智能体名称", trigger: "blur" }],
|
||||
provider: [{ required: true, message: "请选择供应商", trigger: "change" }],
|
||||
model: [{ required: true, message: "请输入模型名称", trigger: "blur" }],
|
||||
api_key: [{ required: true, message: "请输入API Key", trigger: "blur" }],
|
||||
temperature: [{ required: true, message: "请输入温度参数", trigger: "blur" }],
|
||||
system_prompt: [{ required: true, message: "请输入系统提示词", trigger: "blur" }],
|
||||
};
|
||||
|
||||
const loadingData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await AiAPI.listAgentConfig(queryFormData);
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
pageTableData.value = res.data.data?.items || [];
|
||||
total.value = res.data.data?.total || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取智能体配置列表失败:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuery = () => {
|
||||
queryFormData.page_no = 1;
|
||||
loadingData();
|
||||
};
|
||||
|
||||
const handleResetQuery = () => {
|
||||
queryFormRef.value?.resetFields();
|
||||
queryFormData.page_no = 1;
|
||||
loadingData();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadingData();
|
||||
};
|
||||
|
||||
const handleSelectionChange = (selection: AgentConfigTable[]) => {
|
||||
selectIds.value = selection.map((item) => item.id!);
|
||||
};
|
||||
|
||||
const handleOpenDialog = async (type: "create" | "update" | "detail", id?: number) => {
|
||||
dialogVisible.type = type;
|
||||
dialogVisible.id = id || 0;
|
||||
|
||||
if (type === "create") {
|
||||
dialogVisible.title = "新增智能体配置";
|
||||
Object.assign(formData, {
|
||||
name: "",
|
||||
provider: "openai",
|
||||
model: "",
|
||||
api_key: "",
|
||||
base_url: "",
|
||||
temperature: 0.7,
|
||||
system_prompt:
|
||||
"你是一个有用的AI助手,可以帮助用户回答问题和提供帮助。请用中文回答用户的问题。",
|
||||
is_default: false,
|
||||
is_active: true,
|
||||
});
|
||||
} else if (id) {
|
||||
const res = await AiAPI.detailAgentConfig(id);
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
if (type === "update") {
|
||||
dialogVisible.title = "编辑智能体配置";
|
||||
Object.assign(formData, res.data.data);
|
||||
} else {
|
||||
dialogVisible.title = "智能体配置详情";
|
||||
Object.assign(detailFormData, res.data.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialogVisible.visible = true;
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
dialogVisible.visible = false;
|
||||
dataFormRef.value?.resetFields();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isValid = await dataFormRef.value?.validate();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (dialogVisible.type === "create") {
|
||||
res = await AiAPI.createAgentConfig(formData);
|
||||
} else {
|
||||
res = await AiAPI.updateAgentConfig(dialogVisible.id, formData);
|
||||
}
|
||||
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
ElMessage.success(dialogVisible.type === "create" ? "创建成功" : "更新成功");
|
||||
handleCloseDialog();
|
||||
loadingData();
|
||||
} else {
|
||||
ElMessage.error(res.data?.msg || "操作失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("提交失败:", error);
|
||||
ElMessage.error(error.message || "网络错误,请稍后重试");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (ids: number[]) => {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要删除选中的智能体配置吗?此操作不可恢复。", "确认删除", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
const res = await AiAPI.deleteAgentConfig(ids);
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
ElMessage.success("删除成功");
|
||||
loadingData();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
console.error("删除失败:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadingData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.prompt-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="chat-input">
|
||||
<div class="input-wrapper">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="inputMessage"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled || sending"
|
||||
type="textarea"
|
||||
:rows="1"
|
||||
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||
resize="none"
|
||||
class="message-input"
|
||||
@keydown.enter.exact.prevent="handleSend"
|
||||
@keydown.shift.enter.exact="handleShiftEnter"
|
||||
/>
|
||||
<el-button
|
||||
:disabled="!inputMessage.trim() || disabled || sending"
|
||||
:loading="sending"
|
||||
class="send-button"
|
||||
type="primary"
|
||||
circle
|
||||
@click="handleSend"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="input-footer">
|
||||
<span class="input-hint">按 Enter 发送消息,Shift + Enter 换行</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { Promotion } from "@element-plus/icons-vue";
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
sending?: boolean;
|
||||
isConnected?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "send", message: string): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
sending: false,
|
||||
isConnected: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const inputMessage = ref("");
|
||||
|
||||
const placeholder = computed(() => {
|
||||
return props.isConnected ? "向FA助手发送消息..." : "请先连接到服务器";
|
||||
});
|
||||
|
||||
const handleSend = () => {
|
||||
const message = inputMessage.value.trim();
|
||||
if (!message || props.disabled || props.sending) {
|
||||
return;
|
||||
}
|
||||
emit("send", message);
|
||||
inputMessage.value = "";
|
||||
};
|
||||
|
||||
const handleShiftEnter = () => {
|
||||
inputMessage.value += "\n";
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
const input = document.querySelector(".message-input textarea") as HTMLTextAreaElement;
|
||||
input?.focus();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-input {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: var(--el-bg-color);
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
|
||||
.input-wrapper {
|
||||
max-width: 800px;
|
||||
padding: 16px 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
|
||||
:deep(.el-textarea__inner) {
|
||||
padding-right: 40px;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.send-button {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-footer {
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
|
||||
.input-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div ref="messagesContainer" class="chat-messages">
|
||||
<WelcomeScreen v-if="messages.length === 0" @prompt-click="handlePromptClick" />
|
||||
<div v-else class="messages-list">
|
||||
<MessageItem
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
@toggle-fold="handleToggleFold(message)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="error" class="error-banner">
|
||||
<el-alert :title="error" type="error" :closable="true" show-icon @close="handleErrorClose" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from "vue";
|
||||
import WelcomeScreen from "./WelcomeScreen.vue";
|
||||
import MessageItem from "./MessageItem.vue";
|
||||
import type { ChatMessage } from "../types";
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "prompt-click", prompt: string): void;
|
||||
(e: "error-close"): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const messagesContainer = ref<HTMLElement>();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.messages,
|
||||
() => {
|
||||
scrollToBottom();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const handlePromptClick = (prompt: string) => {
|
||||
emit("prompt-click", prompt);
|
||||
};
|
||||
|
||||
const handleToggleFold = (message: ChatMessage) => {
|
||||
message.collapsed = !message.collapsed;
|
||||
};
|
||||
|
||||
const handleErrorClose = () => {
|
||||
emit("error-close");
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding-bottom: 120px;
|
||||
overflow-y: auto;
|
||||
background: var(--el-bg-color);
|
||||
|
||||
.messages-list {
|
||||
max-width: 800px;
|
||||
padding: 24px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
position: fixed;
|
||||
bottom: 140px;
|
||||
left: 50%;
|
||||
z-index: 1000;
|
||||
padding: 0 24px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="chat-navbar">
|
||||
<div class="navbar-left">
|
||||
<h2>FA智能助手</h2>
|
||||
</div>
|
||||
<div class="navbar-right">
|
||||
<div class="connection-status">
|
||||
<el-icon :class="['status-icon', connectionStatus]">
|
||||
<Connection v-if="connectionStatus === 'connected'" />
|
||||
<Loading v-else-if="connectionStatus === 'connecting'" />
|
||||
<Warning v-else />
|
||||
</el-icon>
|
||||
<span class="status-text">{{ connectionStatusText }}</span>
|
||||
</div>
|
||||
<el-button v-if="hasMessages" text :icon="Delete" @click="handleClearChat">
|
||||
清空对话
|
||||
</el-button>
|
||||
<el-button text :icon="Setting" @click="handleToggleConnection">
|
||||
{{ isConnected ? "断开连接" : "重新连接" }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Connection, Loading, Warning, Delete, Setting } from "@element-plus/icons-vue";
|
||||
|
||||
interface Props {
|
||||
connectionStatus: "connected" | "connecting" | "disconnected";
|
||||
isConnected: boolean;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "clear-chat"): void;
|
||||
(e: "toggle-connection"): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const connectionStatusText = computed(() => {
|
||||
switch (props.connectionStatus) {
|
||||
case "connected":
|
||||
return "已连接";
|
||||
case "connecting":
|
||||
return "连接中...";
|
||||
case "disconnected":
|
||||
return "未连接";
|
||||
default:
|
||||
return "未知状态";
|
||||
}
|
||||
});
|
||||
|
||||
const hasMessages = computed(() => props.messageCount > 0);
|
||||
|
||||
const handleClearChat = () => {
|
||||
emit("clear-chat");
|
||||
};
|
||||
|
||||
const handleToggleConnection = () => {
|
||||
emit("toggle-connection");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: var(--el-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
|
||||
.navbar-left {
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
|
||||
.status-icon {
|
||||
&.connected {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
&.connecting {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
&.disconnected {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="main-chat">
|
||||
<ChatNavbar
|
||||
:connection-status="connectionStatus"
|
||||
:is-connected="isConnected"
|
||||
:message-count="messages.length"
|
||||
@clear-chat="handleClearChat"
|
||||
@toggle-connection="toggleConnection"
|
||||
/>
|
||||
<ChatMessages
|
||||
ref="chatMessagesRef"
|
||||
:messages="messages"
|
||||
:error="error"
|
||||
@prompt-click="handlePromptClick"
|
||||
@error-close="error = ''"
|
||||
/>
|
||||
<ChatInput
|
||||
:disabled="!isConnected"
|
||||
:sending="sending"
|
||||
:is-connected="isConnected"
|
||||
@send="handleSendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import ChatNavbar from "./ChatNavbar.vue";
|
||||
import ChatMessages from "./ChatMessages.vue";
|
||||
import ChatInput from "./ChatInput.vue";
|
||||
import type { ChatMessage } from "../types";
|
||||
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const sending = ref(false);
|
||||
const isConnected = ref(false);
|
||||
const connectionStatus = ref<"connected" | "connecting" | "disconnected">("disconnected");
|
||||
const error = ref("");
|
||||
const chatMessagesRef = ref<InstanceType<typeof ChatMessages>>();
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
const WS_URL = import.meta.env.VITE_APP_WS_ENDPOINT + "/api/v1/application/ai/ws";
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectionStatus.value = "connecting";
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket 连接已建立");
|
||||
isConnected.value = true;
|
||||
connectionStatus.value = "connected";
|
||||
ElMessage.success("连接成功");
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
handleWebSocketMessage({ content: event.data });
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log("WebSocket 连接已关闭", event.code, event.reason);
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = "disconnected";
|
||||
finishLoadingMessages();
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket 错误:", error);
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = "disconnected";
|
||||
ElMessage.error("连接失败,请检查服务器状态");
|
||||
finishLoadingMessages();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("创建 WebSocket 连接失败:", err);
|
||||
connectionStatus.value = "disconnected";
|
||||
error.value = "无法创建连接";
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectWebSocket = () => {
|
||||
if (ws) {
|
||||
ws.close(1000, "用户主动断开");
|
||||
ws = null;
|
||||
}
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = "disconnected";
|
||||
finishLoadingMessages();
|
||||
};
|
||||
|
||||
const toggleConnection = () => {
|
||||
if (isConnected.value) {
|
||||
disconnectWebSocket();
|
||||
ElMessage.info("已断开连接");
|
||||
} else {
|
||||
connectWebSocket();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWebSocketMessage = (data: any) => {
|
||||
const lastMessage = messages.value[messages.value.length - 1];
|
||||
|
||||
if (lastMessage && lastMessage.type === "assistant" && lastMessage.loading) {
|
||||
lastMessage.content += data.content || data.message || "";
|
||||
} else {
|
||||
addMessage("assistant", data.content || data.message || "收到回复");
|
||||
}
|
||||
|
||||
chatMessagesRef.value?.scrollToBottom();
|
||||
};
|
||||
|
||||
const handleSendMessage = (message: string) => {
|
||||
if (!message || !isConnected.value || sending.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMessage = messages.value[messages.value.length - 1];
|
||||
if (lastMessage && lastMessage.type === "assistant" && lastMessage.loading) {
|
||||
lastMessage.loading = false;
|
||||
}
|
||||
|
||||
addMessage("user", message);
|
||||
|
||||
const loadingMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
type: "assistant",
|
||||
content: "",
|
||||
timestamp: Date.now(),
|
||||
loading: true,
|
||||
};
|
||||
messages.value.push(loadingMessage);
|
||||
|
||||
sending.value = true;
|
||||
chatMessagesRef.value?.scrollToBottom();
|
||||
|
||||
try {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(message);
|
||||
} else {
|
||||
throw new Error("WebSocket 连接未建立");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("发送消息失败:", err);
|
||||
messages.value.pop();
|
||||
error.value = "发送消息失败,请检查连接状态";
|
||||
ElMessage.error("发送失败");
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addMessage = (type: "user" | "assistant", content: string) => {
|
||||
const message: ChatMessage = {
|
||||
id: generateId(),
|
||||
type,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
collapsed: content.length > 200,
|
||||
};
|
||||
messages.value.push(message);
|
||||
};
|
||||
|
||||
const handleClearChat = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要清空当前对话吗?此操作不可恢复。", "确认清空", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
messages.value = [];
|
||||
ElMessage.success("对话已清空");
|
||||
} catch {
|
||||
ElMessage.info("已取消清空对话");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptClick = (prompt: string) => {
|
||||
handleSendMessage(prompt);
|
||||
};
|
||||
|
||||
const finishLoadingMessages = () => {
|
||||
messages.value.forEach((message) => {
|
||||
if (message.type === "assistant" && message.loading) {
|
||||
message.loading = false;
|
||||
message.collapsed = message.content.length > 200;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const generateId = () => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
connectWebSocket();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectWebSocket();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-chat {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,477 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="search-container">
|
||||
<el-form
|
||||
ref="queryFormRef"
|
||||
:model="queryFormData"
|
||||
:inline="true"
|
||||
label-suffix=":"
|
||||
@submit.prevent="handleQuery"
|
||||
>
|
||||
<el-form-item prop="knowledge_id" label="知识库">
|
||||
<el-select
|
||||
v-model="queryFormData.knowledge_id"
|
||||
placeholder="请选择知识库"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="item in knowledgeList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id ?? 0"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="title" label="文档标题">
|
||||
<el-input v-model="queryFormData.title" placeholder="请输入文档标题" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item prop="file_type" label="文件类型">
|
||||
<el-select
|
||||
v-model="queryFormData.file_type"
|
||||
placeholder="请选择文件类型"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
>
|
||||
<el-option label="文本" value="text" />
|
||||
<el-option label="Markdown" value="markdown" />
|
||||
<el-option label="PDF" value="pdf" />
|
||||
<el-option label="Word" value="word" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="is_indexed" label="索引状态">
|
||||
<el-select
|
||||
v-model="queryFormData.is_indexed"
|
||||
placeholder="请选择索引状态"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
>
|
||||
<el-option :value="true" label="已索引" />
|
||||
<el-option :value="false" label="未索引" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="search-buttons">
|
||||
<el-button type="primary" icon="search" native-type="submit">查询</el-button>
|
||||
<el-button icon="refresh" @click="handleResetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card class="data-table">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>
|
||||
<el-tooltip content="管理知识库中的文档,用于RAG检索增强。">
|
||||
<QuestionFilled class="w-4 h-4 mx-1" />
|
||||
</el-tooltip>
|
||||
知识库文档列表
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="data-table__toolbar">
|
||||
<div class="data-table__toolbar--left">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="1.5">
|
||||
<el-button type="success" icon="plus" @click="handleOpenDialog('create')">
|
||||
新增
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="danger"
|
||||
icon="delete"
|
||||
:disabled="selectIds.length === 0"
|
||||
@click="handleDelete(selectIds)"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="data-table__toolbar--right">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="1.5">
|
||||
<el-tooltip content="刷新">
|
||||
<el-button type="primary" icon="refresh" circle @click="handleRefresh" />
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="dataTableRef"
|
||||
v-loading="loading"
|
||||
:data="pageTableData"
|
||||
highlight-current-row
|
||||
class="data-table__content"
|
||||
border
|
||||
stripe
|
||||
height="390"
|
||||
max-height="390"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column type="index" fixed label="序号" width="60">
|
||||
<template #default="scope">
|
||||
{{ (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文档标题" prop="title" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="知识库" prop="knowledge_id" min-width="120">
|
||||
<template #default="scope">
|
||||
{{ getKnowledgeName(scope.row.knowledge_id) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文件类型" prop="file_type" min-width="100">
|
||||
<template #default="scope">
|
||||
<el-tag>{{ scope.row.file_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分块数量" prop="chunk_count" min-width="80" />
|
||||
<el-table-column label="索引状态" prop="is_indexed" min-width="80">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.is_indexed ? 'success' : 'warning'">
|
||||
{{ scope.row.is_indexed ? "已索引" : "未索引" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" prop="created_time" min-width="180" />
|
||||
<el-table-column fixed="right" label="操作" align="center" min-width="200">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="document"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
icon="delete"
|
||||
@click="handleDelete([scope.row.id])"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<template #footer>
|
||||
<pagination
|
||||
v-model:total="total"
|
||||
v-model:page="queryFormData.page_no"
|
||||
v-model:limit="queryFormData.page_size"
|
||||
@pagination="loadingData"
|
||||
/>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="800px"
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="文档标题" :span="2">
|
||||
{{ detailFormData.title }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="知识库">
|
||||
{{ getKnowledgeName(detailFormData.knowledge_id) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="文件类型">
|
||||
{{ detailFormData.file_type }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分块数量">
|
||||
{{ detailFormData.chunk_count }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="索引状态">
|
||||
<el-tag :type="detailFormData.is_indexed ? 'success' : 'warning'">
|
||||
{{ detailFormData.is_indexed ? "已索引" : "未索引" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="文档内容" :span="2">
|
||||
<div class="document-content">{{ detailFormData.content }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">
|
||||
{{ detailFormData.created_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="2">
|
||||
{{ detailFormData.updated_time }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form
|
||||
ref="dataFormRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item prop="knowledge_id" label="知识库">
|
||||
<el-select
|
||||
v-model="formData.knowledge_id"
|
||||
placeholder="请选择知识库"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in knowledgeList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id ?? 0"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="title" label="文档标题">
|
||||
<el-input v-model="formData.title" placeholder="请输入文档标题" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="content" label="文档内容">
|
||||
<el-input
|
||||
v-model="formData.content"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="请输入文档内容"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="file_type" label="文件类型">
|
||||
<el-select
|
||||
v-model="formData.file_type"
|
||||
placeholder="请选择文件类型"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="文本" value="text" />
|
||||
<el-option label="Markdown" value="markdown" />
|
||||
<el-option label="PDF" value="pdf" />
|
||||
<el-option label="Word" value="word" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { QuestionFilled } from "@element-plus/icons-vue";
|
||||
import AiAPI from "@/api/module_application/ai";
|
||||
import type { DocumentTable, DocumentForm, KnowledgeTable } from "@/api/module_application/ai";
|
||||
|
||||
const queryFormRef = ref();
|
||||
const dataFormRef = ref();
|
||||
const dataTableRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const pageTableData = ref<DocumentTable[]>([]);
|
||||
const total = ref(0);
|
||||
const selectIds = ref<number[]>([]);
|
||||
const knowledgeList = ref<KnowledgeTable[]>([]);
|
||||
|
||||
const queryFormData = reactive({
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
knowledge_id: undefined as number | undefined,
|
||||
title: "",
|
||||
file_type: "",
|
||||
is_indexed: undefined as boolean | undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
visible: false,
|
||||
title: "",
|
||||
type: "create" as "create" | "update" | "detail",
|
||||
id: 0,
|
||||
});
|
||||
|
||||
const formData = reactive<DocumentForm>({
|
||||
knowledge_id: undefined,
|
||||
title: "",
|
||||
content: "",
|
||||
file_type: "text",
|
||||
});
|
||||
|
||||
const detailFormData = reactive<DocumentTable>({
|
||||
knowledge_id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
file_type: "text",
|
||||
chunk_count: 0,
|
||||
is_indexed: false,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
knowledge_id: [{ required: true, message: "请选择知识库", trigger: "change" }],
|
||||
title: [{ required: true, message: "请输入文档标题", trigger: "blur" }],
|
||||
content: [{ required: true, message: "请输入文档内容", trigger: "blur" }],
|
||||
file_type: [{ required: true, message: "请选择文件类型", trigger: "change" }],
|
||||
};
|
||||
|
||||
const loadKnowledgeList = async () => {
|
||||
try {
|
||||
const res = await AiAPI.listKnowledge({ page_no: 1, page_size: 10 });
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
knowledgeList.value = res.data.data?.items || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取知识库列表失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getKnowledgeName = (id: number) => {
|
||||
const knowledge = knowledgeList.value.find((item) => item.id === id);
|
||||
return knowledge?.name || "-";
|
||||
};
|
||||
|
||||
const loadingData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await AiAPI.listDocument(queryFormData);
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
pageTableData.value = res.data.data?.items || [];
|
||||
total.value = res.data.data?.total || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取知识库文档列表失败:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuery = () => {
|
||||
queryFormData.page_no = 1;
|
||||
loadingData();
|
||||
};
|
||||
|
||||
const handleResetQuery = () => {
|
||||
queryFormRef.value?.resetFields();
|
||||
queryFormData.page_no = 1;
|
||||
loadingData();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadingData();
|
||||
};
|
||||
|
||||
const handleSelectionChange = (selection: DocumentTable[]) => {
|
||||
selectIds.value = selection.map((item) => item.id!);
|
||||
};
|
||||
|
||||
const handleOpenDialog = async (type: "create" | "update" | "detail", id?: number) => {
|
||||
dialogVisible.type = type;
|
||||
dialogVisible.id = id || 0;
|
||||
|
||||
if (type === "create") {
|
||||
dialogVisible.title = "新增知识库文档";
|
||||
Object.assign(formData, {
|
||||
knowledge_id: undefined,
|
||||
title: "",
|
||||
content: "",
|
||||
file_type: "text",
|
||||
});
|
||||
} else if (id) {
|
||||
const res = await AiAPI.detailDocument(id);
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
if (type === "update") {
|
||||
dialogVisible.title = "编辑知识库文档";
|
||||
Object.assign(formData, res.data.data);
|
||||
} else {
|
||||
dialogVisible.title = "知识库文档详情";
|
||||
Object.assign(detailFormData, res.data.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialogVisible.visible = true;
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
dialogVisible.visible = false;
|
||||
dataFormRef.value?.resetFields();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isValid = await dataFormRef.value?.validate();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let res;
|
||||
if (dialogVisible.type === "create") {
|
||||
res = await AiAPI.createDocument(formData);
|
||||
} else {
|
||||
res = await AiAPI.updateDocument(dialogVisible.id, formData);
|
||||
}
|
||||
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
ElMessage.success(dialogVisible.type === "create" ? "创建成功" : "更新成功");
|
||||
handleCloseDialog();
|
||||
loadingData();
|
||||
} else {
|
||||
ElMessage.error(res.data?.msg || "操作失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("提交失败:", error);
|
||||
ElMessage.error(error.message || "网络错误,请稍后重试");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (ids: number[]) => {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要删除选中的知识库文档吗?此操作不可恢复。", "确认删除", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
const res = await AiAPI.deleteDocument(ids);
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
ElMessage.success("删除成功");
|
||||
loadingData();
|
||||
} else {
|
||||
ElMessage.error(res.data?.msg || "删除失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== "cancel") {
|
||||
console.error("删除失败:", error);
|
||||
ElMessage.error(error.message || "删除失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadKnowledgeList();
|
||||
loadingData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.document-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,414 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="search-container">
|
||||
<el-form
|
||||
ref="queryFormRef"
|
||||
:model="queryFormData"
|
||||
:inline="true"
|
||||
label-suffix=":"
|
||||
@submit.prevent="handleQuery"
|
||||
>
|
||||
<el-form-item prop="name" label="知识库名称">
|
||||
<el-input v-model="queryFormData.name" placeholder="请输入知识库名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item prop="is_active" label="状态">
|
||||
<el-select
|
||||
v-model="queryFormData.is_active"
|
||||
placeholder="请选择状态"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
>
|
||||
<el-option :value="true" label="启用" />
|
||||
<el-option :value="false" label="停用" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="search-buttons">
|
||||
<el-button type="primary" icon="search" native-type="submit">查询</el-button>
|
||||
<el-button icon="refresh" @click="handleResetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-card class="data-table">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>
|
||||
<el-tooltip content="管理AI知识库,用于RAG检索增强。">
|
||||
<QuestionFilled class="w-4 h-4 mx-1" />
|
||||
</el-tooltip>
|
||||
知识库列表
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="data-table__toolbar">
|
||||
<div class="data-table__toolbar--left">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="1.5">
|
||||
<el-button type="success" icon="plus" @click="handleOpenDialog('create')">
|
||||
新增
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="danger"
|
||||
icon="delete"
|
||||
:disabled="selectIds.length === 0"
|
||||
@click="handleDelete(selectIds)"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="data-table__toolbar--right">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="1.5">
|
||||
<el-tooltip content="刷新">
|
||||
<el-button type="primary" icon="refresh" circle @click="handleRefresh" />
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="dataTableRef"
|
||||
v-loading="loading"
|
||||
:data="pageTableData"
|
||||
highlight-current-row
|
||||
class="data-table__content"
|
||||
border
|
||||
stripe
|
||||
height="390"
|
||||
max-height="390"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column type="index" fixed label="序号" width="60">
|
||||
<template #default="scope">
|
||||
{{ (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="知识库名称" prop="name" min-width="150" />
|
||||
<el-table-column label="描述" prop="description" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="嵌入模型" prop="embedding_model" min-width="120" />
|
||||
<el-table-column label="分块大小" prop="chunk_size" min-width="80" />
|
||||
<el-table-column label="重叠大小" prop="chunk_overlap" min-width="80" />
|
||||
<el-table-column label="状态" prop="is_active" min-width="80">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.is_active ? 'success' : 'danger'">
|
||||
{{ scope.row.is_active ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" prop="created_time" min-width="180" />
|
||||
<el-table-column fixed="right" label="操作" align="center" min-width="200">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="info"
|
||||
size="small"
|
||||
link
|
||||
icon="document"
|
||||
@click="handleOpenDialog('detail', scope.row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
icon="delete"
|
||||
@click="handleDelete([scope.row.id])"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<template #footer>
|
||||
<pagination
|
||||
v-model:total="total"
|
||||
v-model:page="queryFormData.page_no"
|
||||
v-model:limit="queryFormData.page_size"
|
||||
@pagination="loadingData"
|
||||
/>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="知识库名称" :span="2">
|
||||
{{ detailFormData.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">
|
||||
{{ detailFormData.description || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="嵌入模型">
|
||||
{{ detailFormData.embedding_model }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分块大小">
|
||||
{{ detailFormData.chunk_size }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="重叠大小">
|
||||
{{ detailFormData.chunk_overlap }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="detailFormData.is_active ? 'success' : 'danger'">
|
||||
{{ detailFormData.is_active ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">
|
||||
{{ detailFormData.created_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="2">
|
||||
{{ detailFormData.updated_time }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form
|
||||
ref="dataFormRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item prop="name" label="知识库名称">
|
||||
<el-input v-model="formData.name" placeholder="请输入知识库名称" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="description" label="描述">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入描述(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="embedding_model" label="嵌入模型">
|
||||
<el-select
|
||||
v-model="formData.embedding_model"
|
||||
placeholder="请选择嵌入模型"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="Deepseek" value="deepseek" />
|
||||
<el-option label="HuggingFace" value="huggingface" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item prop="chunk_size" label="分块大小">
|
||||
<el-input-number v-model="formData.chunk_size" :min="100" :max="2000" :step="50" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="chunk_overlap" label="重叠大小">
|
||||
<el-input-number v-model="formData.chunk_overlap" :min="0" :max="500" :step="10" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="is_active" label="状态">
|
||||
<el-switch v-model="formData.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { QuestionFilled } from "@element-plus/icons-vue";
|
||||
import AiAPI from "@/api/module_application/ai";
|
||||
import type { KnowledgeTable, KnowledgeForm } from "@/api/module_application/ai";
|
||||
|
||||
const queryFormRef = ref();
|
||||
const dataFormRef = ref();
|
||||
const dataTableRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const pageTableData = ref<KnowledgeTable[]>([]);
|
||||
const total = ref(0);
|
||||
const selectIds = ref<number[]>([]);
|
||||
|
||||
const queryFormData = reactive({
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
name: "",
|
||||
is_active: undefined as boolean | undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
visible: false,
|
||||
title: "",
|
||||
type: "create" as "create" | "update" | "detail",
|
||||
id: 0,
|
||||
});
|
||||
|
||||
const formData = reactive<KnowledgeForm>({
|
||||
name: "",
|
||||
description: "",
|
||||
embedding_model: "openai",
|
||||
chunk_size: 500,
|
||||
chunk_overlap: 50,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const detailFormData = reactive<KnowledgeTable>({
|
||||
name: "",
|
||||
description: "",
|
||||
embedding_model: "",
|
||||
chunk_size: 500,
|
||||
chunk_overlap: 50,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入知识库名称", trigger: "blur" }],
|
||||
embedding_model: [{ required: true, message: "请选择嵌入模型", trigger: "change" }],
|
||||
chunk_size: [{ required: true, message: "请输入分块大小", trigger: "blur" }],
|
||||
chunk_overlap: [{ required: true, message: "请输入重叠大小", trigger: "blur" }],
|
||||
};
|
||||
|
||||
const loadingData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await AiAPI.listKnowledge(queryFormData);
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
pageTableData.value = res.data.data?.items || [];
|
||||
total.value = res.data.data?.total || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取知识库列表失败:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuery = () => {
|
||||
queryFormData.page_no = 1;
|
||||
loadingData();
|
||||
};
|
||||
|
||||
const handleResetQuery = () => {
|
||||
queryFormRef.value?.resetFields();
|
||||
queryFormData.page_no = 1;
|
||||
loadingData();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadingData();
|
||||
};
|
||||
|
||||
const handleSelectionChange = (selection: KnowledgeTable[]) => {
|
||||
selectIds.value = selection.map((item) => item.id!);
|
||||
};
|
||||
|
||||
const handleOpenDialog = async (type: "create" | "update" | "detail", id?: number) => {
|
||||
dialogVisible.type = type;
|
||||
dialogVisible.id = id || 0;
|
||||
|
||||
if (type === "create") {
|
||||
dialogVisible.title = "新增知识库";
|
||||
Object.assign(formData, {
|
||||
name: "",
|
||||
description: "",
|
||||
embedding_model: "openai",
|
||||
chunk_size: 500,
|
||||
chunk_overlap: 50,
|
||||
is_active: true,
|
||||
});
|
||||
} else if (id) {
|
||||
const res = await AiAPI.detailKnowledge(id);
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
if (type === "update") {
|
||||
dialogVisible.title = "编辑知识库";
|
||||
Object.assign(formData, res.data.data);
|
||||
} else {
|
||||
dialogVisible.title = "知识库详情";
|
||||
Object.assign(detailFormData, res.data.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialogVisible.visible = true;
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
dialogVisible.visible = false;
|
||||
dataFormRef.value?.resetFields();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isValid = await dataFormRef.value?.validate();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let res;
|
||||
if (dialogVisible.type === "create") {
|
||||
res = await AiAPI.createKnowledge(formData);
|
||||
} else {
|
||||
res = await AiAPI.updateKnowledge(dialogVisible.id, formData);
|
||||
}
|
||||
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
ElMessage.success(dialogVisible.type === "create" ? "创建成功" : "更新成功");
|
||||
handleCloseDialog();
|
||||
loadingData();
|
||||
} else {
|
||||
ElMessage.error(res.data?.msg || "操作失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("提交失败:", error);
|
||||
ElMessage.error(error.message || "网络错误,请稍后重试");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (ids: number[]) => {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要删除选中的知识库吗?此操作不可恢复。", "确认删除", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
const res = await AiAPI.deleteKnowledge(ids);
|
||||
if (res.data?.code === 0 || res.data?.success === true) {
|
||||
ElMessage.success("删除成功");
|
||||
loadingData();
|
||||
} else {
|
||||
ElMessage.error(res.data?.msg || "删除失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== "cancel") {
|
||||
console.error("删除失败:", error);
|
||||
ElMessage.error(error.message || "删除失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadingData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<div :class="['message-group', message.type]">
|
||||
<div class="message-avatar">
|
||||
<div v-if="message.type === 'user'" class="user-avatar">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div v-else class="ai-avatar">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-header">
|
||||
<strong class="sender-name">
|
||||
{{ message.type === "user" ? "You" : "FA助手" }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<el-button
|
||||
v-if="message.content.length > 200"
|
||||
text
|
||||
size="small"
|
||||
:icon="message.collapsed ? ArrowDown : ArrowUp"
|
||||
class="fold-button"
|
||||
@click="handleToggleFold"
|
||||
>
|
||||
{{ message.collapsed ? "展开" : "收起" }}
|
||||
</el-button>
|
||||
<div
|
||||
class="message-text"
|
||||
:class="{ collapsed: message.collapsed }"
|
||||
v-html="formattedContent"
|
||||
></div>
|
||||
<div
|
||||
v-if="message.type === 'assistant' && message.loading && !message.content"
|
||||
class="typing-indicator"
|
||||
>
|
||||
<div class="typing-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!message.loading" class="message-actions">
|
||||
<el-button text size="small" :icon="CopyDocument" @click="handleCopy"></el-button>
|
||||
<el-button
|
||||
v-if="message.type === 'assistant'"
|
||||
text
|
||||
size="small"
|
||||
:icon="RefreshLeft"
|
||||
></el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
User,
|
||||
ChatDotRound,
|
||||
CopyDocument,
|
||||
RefreshLeft,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
} from "@element-plus/icons-vue";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import markdownItHighlightjs from "markdown-it-highlightjs";
|
||||
import hljs from "highlight.js";
|
||||
import "highlight.js/styles/atom-one-light.css";
|
||||
import type { ChatMessage } from "../types";
|
||||
|
||||
interface Props {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "toggle-fold"): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const md: MarkdownIt = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true,
|
||||
highlight(str: string, lang: string): string {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`;
|
||||
} catch {
|
||||
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
|
||||
}
|
||||
}
|
||||
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
|
||||
},
|
||||
}).use(markdownItHighlightjs);
|
||||
|
||||
const defaultRender =
|
||||
md.renderer.rules.link_open ||
|
||||
function (tokens: any[], idx: number, options: any, env: any, self: any) {
|
||||
return self.renderToken(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
md.renderer.rules.link_open = function (
|
||||
tokens: any[],
|
||||
idx: number,
|
||||
options: any,
|
||||
env: any,
|
||||
self: any
|
||||
) {
|
||||
tokens[idx].attrPush(["target", "_blank"]);
|
||||
tokens[idx].attrPush(["rel", "noopener noreferrer"]);
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
const formattedContent = computed(() => {
|
||||
if (!props.message.content) return "";
|
||||
return md.render(props.message.content);
|
||||
});
|
||||
|
||||
const handleToggleFold = () => {
|
||||
emit("toggle-fold");
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.message.content);
|
||||
ElMessage.success("已复制到剪贴板");
|
||||
} catch {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = props.message.content;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
ElMessage.success("已复制到剪贴板");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.message-avatar {
|
||||
flex-shrink: 0;
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ai-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
background: var(--el-color-success);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.message-header {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.sender-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.message-body {
|
||||
.fold-button {
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-primary);
|
||||
word-wrap: break-word;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.collapsed {
|
||||
position: relative;
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 60px;
|
||||
content: "";
|
||||
background: linear-gradient(transparent, var(--el-bg-color));
|
||||
}
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
overflow-x: auto;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
|
||||
code {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
padding: 2px 6px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 13px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
padding-left: 24px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
padding: 8px 16px;
|
||||
margin: 12px 0;
|
||||
background: var(--el-fill-color-light);
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--el-text-color-secondary);
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
|
||||
&:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="welcome-screen">
|
||||
<div class="welcome-content">
|
||||
<div class="ai-logo">
|
||||
<el-icon size="64"><ChatDotRound /></el-icon>
|
||||
</div>
|
||||
<h1>FA智能助手</h1>
|
||||
<p class="welcome-subtitle">我是您的专属AI助手,可以帮您回答问题、处理任务和进行智能对话</p>
|
||||
|
||||
<div class="example-prompts">
|
||||
<div class="prompt-card" @click="handlePromptClick('请介绍一下FastApiAdmin系统')">
|
||||
<h4>系统介绍</h4>
|
||||
<p>请介绍一下FastApiAdmin系统</p>
|
||||
</div>
|
||||
<div class="prompt-card" @click="handlePromptClick('如何在系统中创建新的模块?')">
|
||||
<h4>开发指导</h4>
|
||||
<p>如何在系统中创建新的模块?</p>
|
||||
</div>
|
||||
<div class="prompt-card" @click="handlePromptClick('系统的权限管理是如何工作的?')">
|
||||
<h4>权限管理</h4>
|
||||
<p>FA系统的权限管理是如何工作的?</p>
|
||||
</div>
|
||||
<div class="prompt-card" @click="handlePromptClick('如何优化FA系统的性能?')">
|
||||
<h4>性能优化</h4>
|
||||
<p>如何优化系统的性能?</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChatDotRound } from "@element-plus/icons-vue";
|
||||
|
||||
interface Emits {
|
||||
(e: "prompt-click", prompt: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handlePromptClick = (prompt: string) => {
|
||||
emit("prompt-click", prompt);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.welcome-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
|
||||
.welcome-content {
|
||||
max-width: 800px;
|
||||
|
||||
.ai-logo {
|
||||
margin-bottom: 24px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
margin-bottom: 32px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.example-prompts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
max-width: 600px;
|
||||
|
||||
.prompt-card {
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: var(--el-bg-color-page);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,952 +1,48 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 主聊天区域 -->
|
||||
<div class="main-chat">
|
||||
<!-- 顶部导航栏 -->
|
||||
<div class="chat-navbar">
|
||||
<div class="navbar-left">
|
||||
<h2>FA智能助手</h2>
|
||||
</div>
|
||||
<div class="navbar-right">
|
||||
<div class="connection-status">
|
||||
<el-icon :class="['status-icon', connectionStatus]">
|
||||
<Connection v-if="connectionStatus === 'connected'" />
|
||||
<Loading v-else-if="connectionStatus === 'connecting'" />
|
||||
<Warning v-else />
|
||||
</el-icon>
|
||||
<span class="status-text">{{ connectionStatusText }}</span>
|
||||
</div>
|
||||
<el-button v-if="messages.length > 0" text :icon="Delete" @click="clearCurrentChat">
|
||||
清空对话
|
||||
</el-button>
|
||||
<el-button text :icon="Setting" @click="toggleConnection">
|
||||
{{ isConnected ? "断开连接" : "重新连接" }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天消息区域 -->
|
||||
<div ref="messagesContainer" class="chat-messages">
|
||||
<!-- 欢迎界面 -->
|
||||
<div v-if="messages.length === 0" class="welcome-screen">
|
||||
<div class="welcome-content">
|
||||
<div class="ai-logo">
|
||||
<el-icon size="64"><ChatDotRound /></el-icon>
|
||||
</div>
|
||||
<h1>FA智能助手</h1>
|
||||
<p class="welcome-subtitle">
|
||||
我是您的专属AI助手,可以帮您回答问题、处理任务和进行智能对话
|
||||
</p>
|
||||
|
||||
<div class="example-prompts">
|
||||
<div class="prompt-card" @click="setPrompt('请介绍一下FastApiAdmin系统')">
|
||||
<h4>系统介绍</h4>
|
||||
<p>请介绍一下FastApiAdmin系统</p>
|
||||
</div>
|
||||
<div class="prompt-card" @click="setPrompt('如何在系统中创建新的模块?')">
|
||||
<h4>开发指导</h4>
|
||||
<p>如何在系统中创建新的模块?</p>
|
||||
</div>
|
||||
<div class="prompt-card" @click="setPrompt('系统的权限管理是如何工作的?')">
|
||||
<h4>权限管理</h4>
|
||||
<p>FA系统的权限管理是如何工作的?</p>
|
||||
</div>
|
||||
<div class="prompt-card" @click="setPrompt('如何优化FA系统的性能?')">
|
||||
<h4>性能优化</h4>
|
||||
<p>如何优化系统的性能?</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div v-else class="messages-list">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:class="['message-group', message.type]"
|
||||
>
|
||||
<div class="message-avatar">
|
||||
<div v-if="message.type === 'user'" class="user-avatar">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div v-else class="ai-avatar">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-header">
|
||||
<strong class="sender-name">
|
||||
{{ message.type === "user" ? "You" : "FA助手" }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<!-- 折叠/展开按钮 -->
|
||||
<el-button
|
||||
v-if="message.content.length > 200"
|
||||
text
|
||||
size="small"
|
||||
:icon="message.collapsed ? ArrowDown : ArrowUp"
|
||||
class="fold-button"
|
||||
@click="toggleMessageFold(message)"
|
||||
>
|
||||
{{ message.collapsed ? "展开" : "收起" }}
|
||||
</el-button>
|
||||
<!-- 实时显示累积的消息内容 -->
|
||||
<div
|
||||
class="message-text"
|
||||
:class="{ collapsed: message.collapsed }"
|
||||
v-html="formatMessage(message.content)"
|
||||
></div>
|
||||
<!-- 只有内容为空且loading时才显示打字指示器 -->
|
||||
<div
|
||||
v-if="message.type === 'assistant' && message.loading && !message.content"
|
||||
class="typing-indicator"
|
||||
>
|
||||
<div class="typing-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!message.loading" class="message-actions">
|
||||
<el-button
|
||||
text
|
||||
size="small"
|
||||
:icon="CopyDocument"
|
||||
@click="copyMessage(message.content)"
|
||||
></el-button>
|
||||
<el-button
|
||||
v-if="message.type === 'assistant'"
|
||||
text
|
||||
size="small"
|
||||
:icon="RefreshLeft"
|
||||
></el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="error-banner">
|
||||
<el-alert :title="error" type="error" :closable="true" show-icon @close="error = ''" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="chat-input">
|
||||
<div class="input-wrapper">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="inputMessage"
|
||||
:placeholder="isConnected ? '向FA助手发送消息...' : '请先连接到服务器'"
|
||||
:disabled="!isConnected || sending"
|
||||
type="textarea"
|
||||
:rows="1"
|
||||
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||
resize="none"
|
||||
class="message-input"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
@keydown.shift.enter.exact="inputMessage += '\n'"
|
||||
/>
|
||||
<el-button
|
||||
:disabled="!inputMessage.trim() || !isConnected || sending"
|
||||
:loading="sending"
|
||||
class="send-button"
|
||||
type="primary"
|
||||
circle
|
||||
@click="sendMessage"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="input-footer">
|
||||
<span class="input-hint">按 Enter 发送消息,Shift + Enter 换行</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<el-tab-pane label="智能对话" name="chat">
|
||||
<ChatView />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="智能体配置" name="agent">
|
||||
<AgentConfigView />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="知识库" name="knowledge">
|
||||
<KnowledgeView />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="知识库文档" name="document">
|
||||
<KnowledgeDocumentView />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick, computed } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
ChatDotRound,
|
||||
User,
|
||||
Delete,
|
||||
Promotion,
|
||||
Connection,
|
||||
Loading,
|
||||
Warning,
|
||||
Setting,
|
||||
CopyDocument,
|
||||
RefreshLeft,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
} from "@element-plus/icons-vue";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import markdownItHighlightjs from "markdown-it-highlightjs";
|
||||
import hljs from "highlight.js";
|
||||
import "highlight.js/styles/atom-one-light.css";
|
||||
import { ref } from "vue";
|
||||
import ChatView from "./components/ChatView.vue";
|
||||
import AgentConfigView from "./components/AgentConfigView.vue";
|
||||
import KnowledgeView from "./components/KnowledgeView.vue";
|
||||
import KnowledgeDocumentView from "./components/KnowledgeDocumentView.vue";
|
||||
|
||||
// 消息接口
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
type: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: number;
|
||||
loading?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
// 创建MarkdownIt实例并配置插件
|
||||
const md: MarkdownIt = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true,
|
||||
highlight(str: string, lang: string): string {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`;
|
||||
} catch {
|
||||
// 忽略错误,使用默认渲染
|
||||
}
|
||||
}
|
||||
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
|
||||
},
|
||||
}).use(markdownItHighlightjs);
|
||||
|
||||
// 配置链接在新窗口打开
|
||||
const defaultRender =
|
||||
md.renderer.rules.link_open ||
|
||||
function (tokens: any[], idx: number, options: any, env: any, self: any) {
|
||||
return self.renderToken(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
md.renderer.rules.link_open = function (
|
||||
tokens: any[],
|
||||
idx: number,
|
||||
options: any,
|
||||
env: any,
|
||||
self: any
|
||||
) {
|
||||
// 添加target="_blank"和rel="noopener noreferrer"属性
|
||||
tokens[idx].attrPush(["target", "_blank"]);
|
||||
tokens[idx].attrPush(["rel", "noopener noreferrer"]);
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
// 响应式数据
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const inputMessage = ref("");
|
||||
const sending = ref(false);
|
||||
const isConnected = ref(false);
|
||||
const connectionStatus = ref<"connected" | "connecting" | "disconnected">("disconnected");
|
||||
const error = ref("");
|
||||
const messagesContainer = ref<HTMLElement>();
|
||||
|
||||
// WebSocket 连接
|
||||
let ws: WebSocket | null = null;
|
||||
const WS_URL = import.meta.env.VITE_APP_WS_ENDPOINT + "/api/v1/application/ai/ws";
|
||||
|
||||
// 计算属性
|
||||
const connectionStatusText = computed(() => {
|
||||
switch (connectionStatus.value) {
|
||||
case "connected":
|
||||
return "已连接";
|
||||
case "connecting":
|
||||
return "连接中...";
|
||||
case "disconnected":
|
||||
return "未连接";
|
||||
default:
|
||||
return "未知状态";
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket 连接管理
|
||||
const connectWebSocket = () => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectionStatus.value = "connecting";
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket 连接已建立");
|
||||
isConnected.value = true;
|
||||
connectionStatus.value = "connected";
|
||||
ElMessage.success("连接成功");
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// 直接处理文本消息,因为后端发送的是流式文本而不是JSON
|
||||
handleWebSocketMessage({ content: event.data });
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log("WebSocket 连接已关闭", event.code, event.reason);
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = "disconnected";
|
||||
|
||||
// 结束所有加载中的助手消息
|
||||
messages.value.forEach((message) => {
|
||||
if (message.type === "assistant" && message.loading) {
|
||||
message.loading = false;
|
||||
// 检查消息长度并设置折叠状态
|
||||
message.collapsed = message.content.length > 200;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket 错误:", error);
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = "disconnected";
|
||||
ElMessage.error("连接失败,请检查服务器状态");
|
||||
|
||||
// 结束所有加载中的助手消息
|
||||
messages.value.forEach((message) => {
|
||||
if (message.type === "assistant" && message.loading) {
|
||||
message.loading = false;
|
||||
// 检查消息长度并设置折叠状态
|
||||
message.collapsed = message.content.length > 200;
|
||||
}
|
||||
});
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("创建 WebSocket 连接失败:", err);
|
||||
connectionStatus.value = "disconnected";
|
||||
error.value = "无法创建连接";
|
||||
}
|
||||
};
|
||||
|
||||
// 断开连接
|
||||
const disconnectWebSocket = () => {
|
||||
if (ws) {
|
||||
ws.close(1000, "用户主动断开");
|
||||
ws = null;
|
||||
}
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = "disconnected";
|
||||
|
||||
// 结束所有加载中的助手消息
|
||||
messages.value.forEach((message) => {
|
||||
if (message.type === "assistant" && message.loading) {
|
||||
message.loading = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 切换连接状态
|
||||
const toggleConnection = () => {
|
||||
if (isConnected.value) {
|
||||
disconnectWebSocket();
|
||||
ElMessage.info("已断开连接");
|
||||
} else {
|
||||
connectWebSocket();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理 WebSocket 消息
|
||||
const handleWebSocketMessage = (data: any) => {
|
||||
// 查找最后一个助手消息
|
||||
const lastMessage = messages.value[messages.value.length - 1];
|
||||
|
||||
if (lastMessage && lastMessage.type === "assistant" && lastMessage.loading) {
|
||||
// 累积流式响应内容,而不是替换
|
||||
lastMessage.content += data.content || data.message || "";
|
||||
|
||||
// 保持加载状态,直到收到完整响应
|
||||
// 注意:如果后端会发送特定的结束信号,需要根据实际情况调整
|
||||
// 例如:if (data.finish_reason || data.is_complete) { lastMessage.loading = false; }
|
||||
} else {
|
||||
// 添加新的助手消息(仅当没有加载中的助手消息时)
|
||||
addMessage("assistant", data.content || data.message || "收到回复");
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = async () => {
|
||||
const message = inputMessage.value.trim();
|
||||
if (!message || !isConnected.value || sending.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 结束上一条助手消息的加载状态(如果存在)
|
||||
const lastMessage = messages.value[messages.value.length - 1];
|
||||
if (lastMessage && lastMessage.type === "assistant" && lastMessage.loading) {
|
||||
lastMessage.loading = false;
|
||||
}
|
||||
|
||||
// 添加用户消息
|
||||
addMessage("user", message);
|
||||
inputMessage.value = "";
|
||||
|
||||
// 添加加载中的助手消息
|
||||
const loadingMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
type: "assistant",
|
||||
content: "",
|
||||
timestamp: Date.now(),
|
||||
loading: true,
|
||||
};
|
||||
messages.value.push(loadingMessage);
|
||||
|
||||
sending.value = true;
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
// 发送消息到 WebSocket
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
// 直接发送纯文本消息,因为后端期望接收纯文本
|
||||
ws.send(message);
|
||||
} else {
|
||||
throw new Error("WebSocket 连接未建立");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("发送消息失败:", err);
|
||||
// 移除加载消息并显示错误
|
||||
messages.value.pop();
|
||||
error.value = "发送消息失败,请检查连接状态";
|
||||
ElMessage.error("发送失败");
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 添加消息
|
||||
const addMessage = (type: "user" | "assistant", content: string) => {
|
||||
const message: ChatMessage = {
|
||||
id: generateId(),
|
||||
type,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
// 长消息自动折叠
|
||||
collapsed: content.length > 200,
|
||||
};
|
||||
messages.value.push(message);
|
||||
nextTick(() => scrollToBottom());
|
||||
};
|
||||
|
||||
const clearCurrentChat = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要清空当前对话吗?此操作不可恢复。", "确认清空", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
messages.value = [];
|
||||
ElMessage.success("对话已清空");
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
};
|
||||
|
||||
// 设置提示词
|
||||
const setPrompt = (prompt: string) => {
|
||||
inputMessage.value = prompt;
|
||||
};
|
||||
|
||||
// 复制消息
|
||||
const copyMessage = async (content: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
ElMessage.success("已复制到剪贴板");
|
||||
} catch {
|
||||
// 降级方案
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = content;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
ElMessage.success("已复制到剪贴板");
|
||||
}
|
||||
};
|
||||
|
||||
// 折叠/展开消息
|
||||
const toggleMessageFold = (message: ChatMessage) => {
|
||||
message.collapsed = !message.collapsed;
|
||||
};
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 格式化消息内容
|
||||
const formatMessage = (content: string) => {
|
||||
if (!content) return "";
|
||||
|
||||
// 使用markdown-it进行完整的Markdown渲染
|
||||
return md.render(content);
|
||||
};
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
connectWebSocket();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectWebSocket();
|
||||
});
|
||||
const activeTab = ref("chat");
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 主聊天区域
|
||||
.main-chat {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.chat-navbar {
|
||||
.app-container {
|
||||
:deep(.el-tabs) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: var(--el-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.navbar-left {
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
.el-tabs__content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.el-tab-pane {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
|
||||
.status-icon {
|
||||
&.connected {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
&.connecting {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
&.disconnected {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding-bottom: 120px; // 为底部输入框留出空间
|
||||
overflow-y: auto;
|
||||
background: var(--el-bg-color);
|
||||
|
||||
// 欢迎界面
|
||||
.welcome-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
|
||||
.welcome-content {
|
||||
max-width: 800px;
|
||||
|
||||
.ai-logo {
|
||||
margin-bottom: 24px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
margin-bottom: 32px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.example-prompts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
max-width: 600px;
|
||||
|
||||
.prompt-card {
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: var(--el-bg-color-page);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 消息列表
|
||||
.messages-list {
|
||||
max-width: 800px;
|
||||
padding: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
.message-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.message-avatar {
|
||||
flex-shrink: 0;
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ai-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
background: var(--el-color-success);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.message-header {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.sender-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.message-body {
|
||||
.fold-button {
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-primary);
|
||||
word-wrap: break-word;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.collapsed {
|
||||
position: relative;
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 40px;
|
||||
content: "";
|
||||
background: linear-gradient(to bottom, transparent, var(--el-bg-color));
|
||||
}
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
margin: 0 0 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
padding: 2px 6px;
|
||||
font-family: "JetBrains Mono", "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
padding: 16px;
|
||||
margin: 12px 0;
|
||||
overflow-x: auto;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--el-text-color-secondary);
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
.el-button {
|
||||
min-height: auto;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
padding: 16px 24px 24px;
|
||||
background: var(--el-bg-color);
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.input-wrapper {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
|
||||
.input-container {
|
||||
position: relative;
|
||||
background: var(--el-bg-color-page);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
|
||||
.message-input {
|
||||
:deep(.el-textarea__inner) {
|
||||
min-height: 52px;
|
||||
padding: 18px 70px 18px 20px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
&:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.send-button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
|
||||
.input-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%,
|
||||
20% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动条样式
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-thumb {
|
||||
background: var(--el-fill-color);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-dark);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
type: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: number;
|
||||
loading?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatQuery {
|
||||
message: string;
|
||||
knowledge_ids?: number[];
|
||||
agent_config_id?: number;
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
id?: number;
|
||||
name: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
api_key: string;
|
||||
base_url?: string;
|
||||
temperature: number;
|
||||
system_prompt: string;
|
||||
is_default?: boolean;
|
||||
is_active?: boolean;
|
||||
created_time?: string;
|
||||
updated_time?: string;
|
||||
created_by?: any;
|
||||
updated_by?: any;
|
||||
}
|
||||
|
||||
export interface AgentConfigQuery {
|
||||
name?: string;
|
||||
provider?: string;
|
||||
is_default?: boolean;
|
||||
is_active?: boolean;
|
||||
created_time?: string[];
|
||||
updated_time?: string[];
|
||||
created_id?: number;
|
||||
updated_id?: number;
|
||||
}
|
||||
|
||||
export interface Knowledge {
|
||||
id?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
embedding_model: string;
|
||||
chunk_size: number;
|
||||
chunk_overlap: number;
|
||||
is_active?: boolean;
|
||||
created_time?: string;
|
||||
updated_time?: string;
|
||||
created_by?: any;
|
||||
updated_by?: any;
|
||||
}
|
||||
|
||||
export interface KnowledgeQuery {
|
||||
name?: string;
|
||||
is_active?: boolean;
|
||||
created_time?: string[];
|
||||
updated_time?: string[];
|
||||
created_id?: number;
|
||||
updated_id?: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeDocument {
|
||||
id?: number;
|
||||
knowledge_id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
file_type: string;
|
||||
file_path?: string;
|
||||
metadata?: Record<string, string>;
|
||||
chunk_count?: number;
|
||||
is_indexed?: boolean;
|
||||
created_time?: string;
|
||||
updated_time?: string;
|
||||
created_by?: any;
|
||||
updated_by?: any;
|
||||
}
|
||||
|
||||
export interface KnowledgeDocumentQuery {
|
||||
knowledge_id?: number;
|
||||
title?: string;
|
||||
file_type?: string;
|
||||
is_indexed?: boolean;
|
||||
created_time?: string[];
|
||||
created_id?: number;
|
||||
}
|
||||
|
||||
export interface ConnectionStatus {
|
||||
connected: boolean;
|
||||
status: "connected" | "connecting" | "disconnected";
|
||||
}
|
||||
@@ -586,6 +586,7 @@
|
||||
<ImportModal
|
||||
v-model="importDialogVisible"
|
||||
:content-config="curdContentConfig"
|
||||
:loading="uploadLoading"
|
||||
@upload="handleUpload"
|
||||
/>
|
||||
|
||||
@@ -768,6 +769,7 @@ const rules = reactive({
|
||||
|
||||
// 导入弹窗显示状态
|
||||
const importDialogVisible = ref(false);
|
||||
const uploadLoading = ref(false);
|
||||
|
||||
// 导出弹窗显示状态
|
||||
const exportsDialogVisible = ref(false);
|
||||
@@ -972,6 +974,7 @@ async function handleMoreClick(status: string) {
|
||||
// 处理上传
|
||||
const handleUpload = async (formData: FormData) => {
|
||||
try {
|
||||
uploadLoading.value = true;
|
||||
const response = await DemoAPI.importDemo(formData);
|
||||
if (response.data.code === ResultEnum.SUCCESS) {
|
||||
ElMessage.success(`${response.data.msg},${response.data.data}`);
|
||||
@@ -980,6 +983,8 @@ const handleUpload = async (formData: FormData) => {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
uploadLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
type="danger"
|
||||
icon="delete"
|
||||
:disabled="selectIds.length === 0"
|
||||
:loading="submitLoading"
|
||||
@click="handleDelete(selectIds)"
|
||||
>
|
||||
批量删除
|
||||
@@ -125,7 +126,11 @@
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-dropdown v-hasPerm="['module_system:user:patch']" trigger="click">
|
||||
<el-button type="default" :disabled="selectIds.length === 0" icon="ArrowDown">
|
||||
<el-button
|
||||
type="default"
|
||||
:disabled="selectIds.length === 0 || submitLoading"
|
||||
icon="ArrowDown"
|
||||
>
|
||||
更多
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
@@ -517,6 +522,7 @@
|
||||
<el-button
|
||||
v-if="dialogVisible.type === 'create' || dialogVisible.type === 'update'"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
确定
|
||||
@@ -531,6 +537,7 @@
|
||||
<ImportModal
|
||||
v-model="importDialogVisible"
|
||||
:content-config="curdContentConfig"
|
||||
:loading="uploadLoading"
|
||||
@upload="handleUpload"
|
||||
/>
|
||||
|
||||
@@ -580,6 +587,8 @@ const queryFormRef = ref();
|
||||
const dataFormRef = ref();
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const uploadLoading = ref(false);
|
||||
const isExpand = ref(false);
|
||||
const isExpandable = ref(true);
|
||||
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "450px" : "90%"));
|
||||
@@ -879,11 +888,9 @@ async function handleOpenDialog(type: "create" | "update" | "detail", id?: numbe
|
||||
|
||||
// 提交表单(防抖)
|
||||
async function handleSubmit() {
|
||||
// 表单校验
|
||||
dataFormRef.value.validate(async (valid: any) => {
|
||||
if (valid) {
|
||||
loading.value = true;
|
||||
// 根据弹窗传入的参数(deatil\create\update)判断走什么逻辑
|
||||
submitLoading.value = true;
|
||||
const id = formData.id;
|
||||
try {
|
||||
if (id) {
|
||||
@@ -894,7 +901,6 @@ async function handleSubmit() {
|
||||
dialogVisible.visible = false;
|
||||
resetForm();
|
||||
handleResetQuery();
|
||||
// 如果当前编辑的是登录用户,更新全局用户状态
|
||||
const userStore = useUserStore();
|
||||
if (id === userStore.basicInfo.id) {
|
||||
await userStore.getUserInfo();
|
||||
@@ -902,7 +908,7 @@ async function handleSubmit() {
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -919,13 +925,13 @@ async function handleDelete(ids: number[]) {
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
submitLoading.value = true;
|
||||
await UserAPI.deleteUser(ids);
|
||||
handleResetQuery();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
submitLoading.value = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -943,13 +949,13 @@ async function handleMoreClick(status: string) {
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
submitLoading.value = true;
|
||||
await UserAPI.batchUser({ ids: selectIds.value, status });
|
||||
handleResetQuery();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
submitLoading.value = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -973,6 +979,7 @@ const emit = defineEmits(["import-success"]);
|
||||
// 上传文件
|
||||
const handleUpload = async (formData: FormData) => {
|
||||
try {
|
||||
uploadLoading.value = true;
|
||||
const response = await UserAPI.importUser(formData);
|
||||
if (response.data.code === ResultEnum.SUCCESS) {
|
||||
ElMessage.success(`${response.data.msg},${response.data.data}`);
|
||||
@@ -983,6 +990,8 @@ const handleUpload = async (formData: FormData) => {
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
ElMessage.error("上传失败:" + error);
|
||||
} finally {
|
||||
uploadLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user