新增 ChatDto 数据传输对象,更新聊天接口以支持知识库功能,优化聊天服务逻辑,调整前端组件以提升用户体验。

This commit is contained in:
ccmjga
2025-06-28 22:31:20 +08:00
parent 3e1d7e6fee
commit b6ecc929b0
30 changed files with 268 additions and 176 deletions

View File

@@ -894,7 +894,7 @@
"content": {
"application/json": {
"schema": {
"type": "string"
"$ref": "#/components/schemas/ChatDto"
}
}
},
@@ -1580,7 +1580,8 @@
"DocUpdateDto": {
"required": [
"enable",
"id"
"id",
"libId"
],
"type": "object",
"properties": {
@@ -1588,6 +1589,10 @@
"type": "integer",
"format": "int64"
},
"libId": {
"type": "integer",
"format": "int64"
},
"enable": {
"type": "boolean"
}
@@ -1868,6 +1873,29 @@
}
}
},
"ChatDto": {
"required": [
"message",
"mode"
],
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": [
"NORMAL",
"WITH_LIBRARY"
]
},
"libraryId": {
"type": "integer",
"format": "int64"
},
"message": {
"type": "string"
}
}
},
"PageRequestDto": {
"type": "object",
"properties": {

View File

@@ -783,6 +783,8 @@ export interface components {
DocUpdateDto: {
/** Format: int64 */
id: number;
/** Format: int64 */
libId: number;
enable: boolean;
};
LlmVm: {
@@ -867,6 +869,13 @@ export interface components {
username: string;
password: string;
};
ChatDto: {
/** @enum {string} */
mode: "NORMAL" | "WITH_LIBRARY";
/** Format: int64 */
libraryId?: number;
message: string;
};
PageRequestDto: {
/** Format: int64 */
page?: number;
@@ -1888,7 +1897,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": string;
"application/json": components["schemas"]["ChatDto"];
};
};
responses: {

View File

@@ -5,14 +5,18 @@
<div class="flex flex-col gap-y-5 flex-1 pb-2">
<li v-for="chatElement in messages" :key="chatElement.content"
:class="['flex items-start gap-2.5', chatElement.isUser ? 'flex-row-reverse' : 'flex-row']">
<Avatar :src="chatElement.isUser ? user.avatar : '/trump.jpg'" size="sm"
<Avatar :src="chatElement.isUser ? user.avatar : undefined" size="sm"
:alt="chatElement.isUser ? '用户头像' : 'AI头像'" />
<div
:class="['flex flex-col leading-1.5 p-4 border-gray-200 rounded-e-xl rounded-es-xl max-w-[calc(100%-40px)]', chatElement.isUser ? 'bg-blue-100' : 'bg-gray-100']">
:class="['flex flex-col leading-1.5 p-4 border-gray-200 max-w-[calc(100%-40px)]', chatElement.isUser ? 'bg-blue-100 rounded-tl-xl rounded-bl-xl rounded-br-xl' : 'bg-gray-100 rounded-e-xl rounded-es-xl']">
<div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-gray-900 ">{{ chatElement.username }}</span>
<LoadingIcon :textColor="'text-gray-900'"
v-if="isLoading && !chatElement.isUser && chatElement.content === ''" />
<span v-if="!chatElement.isUser && chatElement.withLibrary"
class="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">
{{ chatElement.libraryName }}
</span>
</div>
<div>
<div class="markdown-content markdown-body text-base font-normal py-2.5 text-gray-900 break-words"
@@ -34,14 +38,24 @@
</div>
<form class="sticky">
<button @click.prevent="clearConversation"
class="relative inline-flex items-center justify-center p-0.5 mb-2 me-2
overflow-hidden text-sm font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-purple-600 to-blue-500 group-hover:from-purple-600 group-hover:to-blue-500 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 ">
<span
class="relative px-3 py-2 text-xs font-medium transition-all ease-in duration-75 bg-white rounded-md group-hover:bg-transparent">
开启新对话
</span>
</button>
<div class="flex items-center justify-between gap-2 mb-2">
<button @click.prevent="clearConversation"
class="relative inline-flex items-center justify-center p-0.5
overflow-hidden text-sm font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-purple-600 to-blue-500 group-hover:from-purple-600 group-hover:to-blue-500 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 ">
<span
class="relative px-3 py-2 text-xs font-medium transition-all ease-in duration-75 bg-white rounded-md group-hover:bg-transparent">
开启新对话
</span>
</button>
<select v-if="commandMode === 'chat'" v-model="selectedLibraryId"
class="bg-white border border-gray-300 text-gray-900 text-xs rounded-lg py-2 px-2 flex-1 max-w-48">
<option :value="undefined">不使用知识库</option>
<option v-for="library in libraries" :key="library.id" :value="library.id">
{{ library.name }}
</option>
</select>
</div>
<div class="w-full border border-gray-200 rounded-lg bg-gray-50">
<div class="px-4 py-2 bg-white rounded-t-lg">
<label for="comment" class="sr-only"></label>
@@ -51,7 +65,7 @@
" required></textarea>
</div>
<div class="flex justify-between px-2 py-2 border-t border-gray-200">
<select id="countries" v-model="commandMode"
<select id="commandMode" v-model="commandMode"
class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg block">
<option selected :value="'execute'">指令模式</option>
<option :value="'search'">搜索模式</option>
@@ -62,7 +76,6 @@
{{ isLoading ? '中止' : '发送' }}
</TableButton>
</div>
</div>
</form>
</div>
@@ -144,6 +157,9 @@ import PositionDeleteModal from "@/components/modals/ConfirmationDialog.vue";
import PositionFormDialog from "@/components/modals/PositionFormDialog.vue";
import RoleDeleteModal from "@/components/modals/ConfirmationDialog.vue";
import RoleFormDialog from "@/components/modals/RoleFormDialog.vue";
import { useKnowledgeQuery } from "@/composables/knowledge/useKnowledgeQuery";
import { UserFormDialog } from "../modals";
import { computed } from "vue";
const {
messages,
@@ -190,6 +206,15 @@ const actionExcStore = useActionExcStore();
const { availableDepartments, fetchAvailableDepartments } =
useDepartmentQuery();
// 知识库相关
const { libraries, fetchLibraries } = useKnowledgeQuery();
const selectedLibraryId = ref<number | null | undefined>(undefined);
const selectedLibraryName = computed(() => {
return libraries.value.find(
(library) => library.id === selectedLibraryId.value,
)?.name;
});
const commandPlaceholderMap: Record<string, string> = {
chat: "随便聊聊",
search: "输入「创建用户、删除部门、创建岗位、创建角色、创建权限」试试看",
@@ -235,31 +260,6 @@ const renderMarkdown = (content: string | undefined) => {
// console.log('处理后HTML:', renderMarkdown(newVal[newVal.length - 1]));
// }, { deep: true });
const handleDeleteUserClick = (input: string) => {
currentDeleteUsername.value = input;
userDeleteModal.value?.show();
};
const handleDeleteDepartmentClick = (input: string) => {
currentDeleteDepartmentName.value = input;
departmentDeleteModal.value?.show();
};
const handleDeletePositionClick = (input: string) => {
currentDeletePositionName.value = input;
positionDeleteModal.value?.show();
};
const handleDeleteRoleClick = (input: string) => {
currentDeleteRoleName.value = input;
roleDeleteModal.value?.show();
};
const handleDeletePermissionClick = (input: string) => {
currentDeletePermissionName.value = input;
permissionDeleteModal.value?.show();
};
const handleUpsertUserSubmit = async (data: UserUpsertSubmitModel) => {
await userUpsert.upsertUser(data);
userUpsertModal.value?.hide();
@@ -402,7 +402,12 @@ const chatByMode = async (
await executeAction(message);
actionExcStore.notify(true);
} else {
await chat(message);
// 聊天模式,判断是否使用知识库
if (selectedLibraryId.value !== undefined) {
await chat(message, selectedLibraryId.value);
} else {
await chat(message);
}
}
};
@@ -427,6 +432,9 @@ onUnmounted(() => {
onMounted(async () => {
initFlowbite();
// 加载知识库列表
await fetchLibraries();
const $upsertModalElement: HTMLElement | null =
document.querySelector("#user-upsert-modal");
if ($upsertModalElement) {

View File

@@ -36,9 +36,9 @@
<script setup lang="ts">
defineProps({
titleClass: {
type: String,
default: ''
}
titleClass: {
type: String,
default: "",
},
});
</script>

View File

@@ -1,4 +1,4 @@
import CardBase from "./CardBase.vue";
import PromotionBanner from "./PromotionBanner.vue";
export { CardBase, PromotionBanner };
export { CardBase, PromotionBanner };

View File

@@ -10,7 +10,7 @@
</template>
<template #footer-left>
<span class="text-xs text-gray-500">
上传时间: {{ formatDateString(doc.createTime) }}
{{ formatDateString(doc.createTime) }}
</span>
</template>
<template #footer-actions>
@@ -20,12 +20,12 @@
</template>
<script setup lang="ts">
import CardBase from '@/components/common/CardBase.vue';
import { KnowledgeStatusBadge } from '@/components/common/knowledge';
import CardBase from "@/components/common/CardBase.vue";
import { KnowledgeStatusBadge } from "@/components/common/knowledge";
import type { LibraryDoc } from "@/types/KnowledgeTypes";
import { formatDateString } from '@/utils/dateUtil';
import { formatDateString } from "@/utils/dateUtil";
const props = defineProps<{
doc: LibraryDoc;
doc: LibraryDoc;
}>();
</script>

View File

@@ -21,11 +21,11 @@
</template>
<script setup lang="ts">
import CardBase from '@/components/common/CardBase.vue';
import CardBase from "@/components/common/CardBase.vue";
import type { Library } from "@/types/KnowledgeTypes";
import { formatDateString } from '@/utils/dateUtil';
import { formatDateString } from "@/utils/dateUtil";
const props = defineProps<{
library: Library;
library: Library;
}>();
</script>

View File

@@ -10,28 +10,28 @@
import { DocStatus } from "@/types/KnowledgeTypes";
const props = defineProps<{
status?: string;
enabled?: boolean;
type: 'status' | 'enabled';
status?: string;
enabled?: boolean;
type: "status" | "enabled";
}>();
const getStatusClass = () => {
if (props.type === 'status') {
return props.status === DocStatus.SUCCESS
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800';
}
return props.enabled
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800';
if (props.type === "status") {
return props.status === DocStatus.SUCCESS
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800";
}
return props.enabled
? "bg-blue-100 text-blue-800"
: "bg-gray-100 text-gray-800";
};
const getStatusText = () => {
if (props.type === 'status') {
return props.status === DocStatus.SUCCESS ? '解析完成' : '解析中';
}
return props.enabled ? '已启用' : '已禁用';
if (props.type === "status") {
return props.status === DocStatus.SUCCESS ? "解析完成" : "解析中";
}
return props.enabled ? "已启用" : "已禁用";
};
</script>

View File

@@ -27,11 +27,11 @@
</template>
<script setup lang="ts">
import CardBase from '@/components/common/CardBase.vue';
import CardBase from "@/components/common/CardBase.vue";
import type { LibraryDocSegment } from "@/types/KnowledgeTypes";
const props = defineProps<{
segment: LibraryDocSegment;
index: number;
segment: LibraryDocSegment;
index: number;
}>();
</script>

View File

@@ -8,4 +8,4 @@ export {
KnowledgeDocCard,
KnowledgeLibraryCard,
SegmentCard,
};
};

View File

@@ -1,7 +1,4 @@
import FormInput from './FormInput.vue';
import FormSelect from './FormSelect.vue';
import FormInput from "./FormInput.vue";
import FormSelect from "./FormSelect.vue";
export {
FormInput,
FormSelect
};
export { FormInput, FormSelect };

View File

@@ -29,7 +29,18 @@
import { Modal, initFlowbite } from "flowbite";
import { computed, onMounted } from "vue";
export type ModalSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "7xl";
export type ModalSize =
| "xs"
| "sm"
| "md"
| "lg"
| "xl"
| "2xl"
| "3xl"
| "4xl"
| "5xl"
| "6xl"
| "7xl";
const props = defineProps<{
/** 对话框标题 */

View File

@@ -20,4 +20,4 @@ export {
RoleFormDialog,
SchedulerFormDialog,
UserFormDialog,
};
};

View File

@@ -12,4 +12,4 @@ export {
TableFilterForm,
TableFormLayout,
TablePagination,
};
};

View File

@@ -25,7 +25,7 @@ const sizeClass = computed(() => {
sm: "w-8 h-8",
md: "w-10 h-10",
lg: "w-12 h-12",
xl: "w-16 h-16"
xl: "w-16 h-16",
};
return sizes[props.size || "md"];
});

View File

@@ -28,95 +28,95 @@ import { StopIcon } from "@/components/icons";
import { computed } from "vue";
export type ButtonVariant =
| "primary"
| "secondary"
| "success"
| "danger"
| "warning"
| "info";
| "primary"
| "secondary"
| "success"
| "danger"
| "warning"
| "info";
export type ButtonSize = "xs" | "sm" | "md" | "lg";
export type ButtonType = "button" | "submit" | "reset";
const props = defineProps<{
/** 按钮变体类型 */
variant?: ButtonVariant;
/** 按钮尺寸 */
size?: ButtonSize;
/** 是否禁用 */
disabled?: boolean;
/** 自定义CSS类名 */
className?: string;
/** 是否为移动端尺寸 */
isMobile?: boolean;
/** 是否处于加载状态 */
isLoading?: boolean;
/** 是否可中止 */
abortable?: boolean;
/** 按钮类型 */
type?: ButtonType;
/** 是否占满宽度 */
fullWidth?: boolean;
/** 按钮变体类型 */
variant?: ButtonVariant;
/** 按钮尺寸 */
size?: ButtonSize;
/** 是否禁用 */
disabled?: boolean;
/** 自定义CSS类名 */
className?: string;
/** 是否为移动端尺寸 */
isMobile?: boolean;
/** 是否处于加载状态 */
isLoading?: boolean;
/** 是否可中止 */
abortable?: boolean;
/** 按钮类型 */
type?: ButtonType;
/** 是否占满宽度 */
fullWidth?: boolean;
}>();
const emit = defineEmits<{
click: [event: MouseEvent];
click: [event: MouseEvent];
}>();
/** 按钮颜色样式映射 */
const colorClasses = computed(() => {
const variants: Record<ButtonVariant, string> = {
primary: "text-white bg-blue-700 hover:bg-blue-800 focus:ring-blue-300",
secondary:
"text-gray-900 bg-white border border-gray-300 hover:bg-gray-100 focus:ring-gray-100",
success: "text-white bg-green-700 hover:bg-green-800 focus:ring-green-300",
danger: "text-white bg-red-700 hover:bg-red-800 focus:ring-red-300",
warning:
"text-gray-900 bg-yellow-400 hover:bg-yellow-500 focus:ring-yellow-300",
info: "text-white bg-cyan-700 hover:bg-cyan-800 focus:ring-cyan-300",
};
const variants: Record<ButtonVariant, string> = {
primary: "text-white bg-blue-700 hover:bg-blue-800 focus:ring-blue-300",
secondary:
"text-gray-900 bg-white border border-gray-300 hover:bg-gray-100 focus:ring-gray-100",
success: "text-white bg-green-700 hover:bg-green-800 focus:ring-green-300",
danger: "text-white bg-red-700 hover:bg-red-800 focus:ring-red-300",
warning:
"text-gray-900 bg-yellow-400 hover:bg-yellow-500 focus:ring-yellow-300",
info: "text-white bg-cyan-700 hover:bg-cyan-800 focus:ring-cyan-300",
};
return variants[props.variant || "primary"];
return variants[props.variant || "primary"];
});
/** 按钮尺寸样式映射 */
const sizeClasses = computed(() => {
// 移动端尺寸
if (props.isMobile) {
const sizes: Record<ButtonSize, string> = {
xs: "text-xs px-2 py-1",
sm: "text-xs px-3 py-1.5",
md: "text-sm px-3 py-2",
lg: "text-sm px-4 py-2.5",
};
return sizes[props.size || "sm"];
}
// 移动端尺寸
if (props.isMobile) {
const sizes: Record<ButtonSize, string> = {
xs: "text-xs px-2 py-1",
sm: "text-xs px-3 py-1.5",
md: "text-sm px-3 py-2",
lg: "text-sm px-4 py-2.5",
};
return sizes[props.size || "sm"];
}
// PC端尺寸
const sizes: Record<ButtonSize, string> = {
xs: "text-xs px-3 py-1.5",
sm: "text-sm px-3 py-2",
md: "text-sm px-4 py-2.5",
lg: "text-base px-5 py-3",
};
// PC端尺寸
const sizes: Record<ButtonSize, string> = {
xs: "text-xs px-3 py-1.5",
sm: "text-sm px-3 py-2",
md: "text-sm px-4 py-2.5",
lg: "text-base px-5 py-3",
};
return sizes[props.size || "md"];
return sizes[props.size || "md"];
});
/** 图标尺寸样式映射 */
const iconSizeClasses = computed(() => {
const sizes: Record<ButtonSize, string> = {
xs: "w-3.5 h-3.5",
sm: "w-4 h-4",
md: "w-4.5 h-4.5",
lg: "w-5 h-5",
};
return sizes[props.size || "md"];
const sizes: Record<ButtonSize, string> = {
xs: "w-3.5 h-3.5",
sm: "w-4 h-4",
md: "w-4.5 h-4.5",
lg: "w-5 h-5",
};
return sizes[props.size || "md"];
});
/** 处理点击事件 */
const handleClick = (event: MouseEvent) => {
if (!props.disabled && !(props.isLoading && !props.abortable)) {
emit("click", event);
}
if (!props.disabled && !(props.isLoading && !props.abortable)) {
emit("click", event);
}
};
</script>

View File

@@ -4,4 +4,4 @@ import Button from "./Button.vue";
import InputButton from "./InputButton.vue";
import SortIcon from "./SortIcon.vue";
export { Alert, Avatar, Button, InputButton, SortIcon };
export { Alert, Avatar, Button, InputButton, SortIcon };

View File

@@ -11,13 +11,19 @@ export const useAiChat = () => {
isUser: boolean;
username: string;
command?: string;
withLibrary?: boolean;
libraryName?: string;
}[]
>([]);
const isLoading = ref(false);
let currentController: AbortController | null = null;
const chat = async (message: string) => {
const chat = async (
message: string,
libraryId?: number | null,
libraryName?: string,
) => {
isLoading.value = true;
const authStore = useAuthStore();
const ctrl = new AbortController();
@@ -27,6 +33,8 @@ export const useAiChat = () => {
type: "chat",
isUser: false,
username: "知路智能体",
withLibrary: libraryId !== undefined,
libraryName: libraryName,
});
try {
const baseUrl = `${import.meta.env.VITE_BASE_URL}`;
@@ -36,7 +44,11 @@ export const useAiChat = () => {
Authorization: authStore.get(),
"Content-Type": "application/json",
},
body: message,
body: JSON.stringify({
mode: libraryId !== undefined ? "WITH_LIBRARY" : "NORMAL",
libraryId: libraryId,
message: message,
}),
signal: ctrl.signal,
onmessage(ev) {
messages.value[messages.value.length - 1].content += ev.data;

View File

@@ -3,4 +3,4 @@ import { usePagination } from "./usePagination";
import { useSorting } from "./useSorting";
import { useStyleSystem } from "./useStyleSystem";
export { useErrorHandling, usePagination, useSorting, useStyleSystem };
export { useErrorHandling, usePagination, useSorting, useStyleSystem };

View File

@@ -26,9 +26,9 @@ export interface DocQueryParams {
}
export interface SegmentQueryParams {
libraryDocId?: number;
docId?: number;
}
libraryDocId?: number;
docId?: number;
}
export enum DocStatus {
SUCCESS = "SUCCESS",

View File

@@ -1,4 +1,4 @@
import { getUserAvatarUrl } from "./avatarUtil";
import { dayjs, formatDate, formatDateString } from "./dateUtil";
export { getUserAvatarUrl, dayjs, formatDate, formatDateString };
export { getUserAvatarUrl, dayjs, formatDate, formatDateString };

View File

@@ -68,7 +68,7 @@ import { useKnowledgeQuery } from "@/composables/knowledge/useKnowledgeQuery";
import { useKnowledgeUpsert } from "@/composables/knowledge/useKnowledgeUpsert";
import useAlertStore from "@/composables/store/useAlertStore";
import { Routes } from "@/router/constants";
import { formatDateString } from '@/utils/dateUtil';
import { formatDateString } from "@/utils/dateUtil";
import type { Library, LibraryDoc } from "@/types/KnowledgeTypes";
import { DocStatus } from "@/types/KnowledgeTypes";

View File

@@ -28,9 +28,6 @@
<!-- 空状态 -->
<div v-else class="flex flex-col items-center justify-center py-10">
<div class="text-gray-500 text-lg mb-4">暂无分段内容</div>
<Button variant="secondary" @click="navigateBack">
返回文档列表
</Button>
</div>
</div>
</template>
@@ -55,7 +52,8 @@ const libraryId = Number.parseInt(route.params.libraryId as string, 10);
const docId = Number.parseInt(route.params.docId as string, 10);
// 获取文档信息和分段列表
const { docs, segments, fetchLibraryDocs, fetchDocSegments } = useKnowledgeQuery();
const { docs, segments, fetchLibraryDocs, fetchDocSegments } =
useKnowledgeQuery();
const currentDoc = ref<LibraryDoc | undefined>();
// 导航回文档列表