feat(ai): 新增智能助手功能模块

feat(backend): 添加ChromaDB向量数据库支持
feat(backend): 实现智能体配置、知识库和文档管理
feat(backend): 重构WebSocket聊天服务为AgentService
feat(frontend): 实现完整的聊天界面组件
feat(frontend): 添加智能体配置、知识库和文档管理页面
fix(user): 修复用户导入时性别转换问题
style(import): 优化导入组件加载状态处理
This commit is contained in:
zhangtao
2026-02-09 01:22:48 +08:00
parent b4dcdfa1ab
commit 45dad1e256
34 changed files with 5803 additions and 1214 deletions
+230
View File
@@ -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;
}
+7 -8
View File
@@ -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;
}
};
+2 -7
View File
@@ -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;
}
};
+19 -10
View File
@@ -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;
}
};