feat:【ele】【ai】chat 的迁移(初始化)
This commit is contained in:
3
apps/web-ele/src/components/markdown-view/index.ts
Normal file
3
apps/web-ele/src/components/markdown-view/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as MarkdownView } from './markdown-view.vue';
|
||||
|
||||
export * from './typing';
|
||||
206
apps/web-ele/src/components/markdown-view/markdown-view.vue
Normal file
206
apps/web-ele/src/components/markdown-view/markdown-view.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import type { MarkdownViewProps } from './typing';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { MarkdownIt } from '@vben/plugins/markmap';
|
||||
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
import 'highlight.js/styles/vs2015.min.css';
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps<MarkdownViewProps>();
|
||||
|
||||
const { copy } = useClipboard(); // 初始化 copy 到粘贴板
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const md = new MarkdownIt({
|
||||
highlight(str, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
const copyHtml = `<div id="copy" data-copy='${str}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`;
|
||||
return `<pre style="position: relative;">${copyHtml}<code class="hljs">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`;
|
||||
} catch {}
|
||||
}
|
||||
return ``;
|
||||
},
|
||||
});
|
||||
|
||||
/** 渲染 markdown */
|
||||
const renderedMarkdown = computed(() => {
|
||||
return md.render(props.content);
|
||||
});
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
// 添加 copy 监听
|
||||
contentRef.value?.addEventListener('click', (e: any) => {
|
||||
if (e.target.id === 'copy') {
|
||||
copy(e.target?.dataset?.copy);
|
||||
ElMessage.success('复制成功!');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="contentRef" class="markdown-view" v-html="renderedMarkdown"></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.markdown-view {
|
||||
max-width: 100%;
|
||||
font-family: 'PingFang SC';
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.6rem;
|
||||
color: #3b3e55;
|
||||
text-align: left;
|
||||
letter-spacing: 0;
|
||||
|
||||
pre {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
pre code.hljs {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
width: auto;
|
||||
padding-top: 20px;
|
||||
border-radius: 6px;
|
||||
|
||||
@media screen and (min-width: 1536px) {
|
||||
width: 960px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1536px) and (min-width: 1024px) {
|
||||
width: calc(100vw - 400px - 64px - 32px * 2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) and (min-width: 768px) {
|
||||
width: calc(100vw - 32px * 2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
width: calc(100vw - 16px * 2);
|
||||
}
|
||||
}
|
||||
|
||||
p,
|
||||
code.hljs {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
//margin-bottom: 1rem !important;
|
||||
margin: 0;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
/* 标题通用格式 */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 24px 0 8px;
|
||||
font-weight: 600;
|
||||
color: #3b3e55;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* 列表(有序,无序) */
|
||||
ul,
|
||||
ol {
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #3b3e55; // var(--color-CG600);
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 4px 0 0 20px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol > li {
|
||||
margin-bottom: 1rem;
|
||||
list-style-type: decimal;
|
||||
// 表达式,修复有序列表序号展示不全的问题
|
||||
// &:nth-child(n + 10) {
|
||||
// margin-left: 30px;
|
||||
// }
|
||||
|
||||
// &:nth-child(n + 100) {
|
||||
// margin-left: 30px;
|
||||
// }
|
||||
}
|
||||
|
||||
ul > li {
|
||||
margin-right: 11px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #3b3e55; // var(--color-G900);
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol ul,
|
||||
ol ul > li,
|
||||
ul ul,
|
||||
ul ul li {
|
||||
margin-bottom: 1rem;
|
||||
margin-left: 6px;
|
||||
// list-style: circle;
|
||||
font-size: 16px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
ul ul ul,
|
||||
ul ul ul li,
|
||||
ol ol,
|
||||
ol ol > li,
|
||||
ol ul ul,
|
||||
ol ul ul > li,
|
||||
ul ol,
|
||||
ul ol > li {
|
||||
list-style: square;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
apps/web-ele/src/components/markdown-view/typing.ts
Normal file
3
apps/web-ele/src/components/markdown-view/typing.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type MarkdownViewProps = {
|
||||
content: string;
|
||||
};
|
||||
77
apps/web-ele/src/views/ai/chat/index/data.ts
Normal file
77
apps/web-ele/src/views/ai/chat/index/data.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { AiModelTypeEnum } from '@vben/constants';
|
||||
|
||||
import { getModelSimpleList } from '#/api/ai/model/model';
|
||||
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'systemMessage',
|
||||
label: '角色设定',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
rows: 4,
|
||||
placeholder: '请输入角色设定',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'ApiSelect',
|
||||
fieldName: 'modelId',
|
||||
label: '模型',
|
||||
componentProps: {
|
||||
api: () => getModelSimpleList(AiModelTypeEnum.CHAT),
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
allowClear: true,
|
||||
placeholder: '请选择模型',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'temperature',
|
||||
label: '温度参数',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入温度参数',
|
||||
class: 'w-full',
|
||||
precision: 2,
|
||||
min: 0,
|
||||
max: 2,
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'maxTokens',
|
||||
label: '回复数 Token 数',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入回复数 Token 数',
|
||||
class: 'w-full',
|
||||
min: 0,
|
||||
max: 8192,
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'maxContexts',
|
||||
label: '上下文数量',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入上下文数量',
|
||||
class: 'w-full',
|
||||
min: 0,
|
||||
max: 20,
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
690
apps/web-ele/src/views/ai/chat/index/index.vue
Normal file
690
apps/web-ele/src/views/ai/chat/index/index.vue
Normal file
@@ -0,0 +1,690 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
|
||||
import type { AiChatMessageApi } from '#/api/ai/chat/message';
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { alert, confirm, Page, useVbenModal } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElContainer,
|
||||
ElFooter,
|
||||
ElHeader,
|
||||
ElMain,
|
||||
ElMessage,
|
||||
ElSwitch,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getChatConversationMy } from '#/api/ai/chat/conversation';
|
||||
import {
|
||||
deleteByConversationId,
|
||||
getChatMessageListByConversationId,
|
||||
sendChatMessageStream,
|
||||
} from '#/api/ai/chat/message';
|
||||
|
||||
import ConversationList from './modules/conversation/list.vue';
|
||||
import ConversationUpdateForm from './modules/conversation/update-form.vue';
|
||||
import MessageFileUpload from './modules/message/file-upload.vue';
|
||||
import MessageListEmpty from './modules/message/list-empty.vue';
|
||||
import MessageList from './modules/message/list.vue';
|
||||
import MessageLoading from './modules/message/loading.vue';
|
||||
import MessageNewConversation from './modules/message/new-conversation.vue';
|
||||
|
||||
/** AI 聊天对话 列表 */
|
||||
defineOptions({ name: 'AiChat' });
|
||||
|
||||
const route = useRoute();
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: ConversationUpdateForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 聊天对话
|
||||
const conversationListRef = ref();
|
||||
const activeConversationId = ref<null | number>(null); // 选中的对话编号
|
||||
const activeConversation = ref<AiChatConversationApi.ChatConversation | null>(
|
||||
null,
|
||||
); // 选中的 Conversation
|
||||
const conversationInProgress = ref(false); // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作
|
||||
|
||||
// 消息列表
|
||||
const messageRef = ref();
|
||||
const activeMessageList = ref<AiChatMessageApi.ChatMessage[]>([]); // 选中对话的消息列表
|
||||
const activeMessageListLoading = ref<boolean>(false); // activeMessageList 是否正在加载中
|
||||
const activeMessageListLoadingTimer = ref<any>(); // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中
|
||||
// 消息滚动
|
||||
const textSpeed = ref<number>(50); // Typing speed in milliseconds
|
||||
const textRoleRunning = ref<boolean>(false); // Typing speed in milliseconds
|
||||
|
||||
// 发送消息输入框
|
||||
const isComposing = ref(false); // 判断用户是否在输入
|
||||
const conversationInAbortController = ref<any>(); // 对话进行中 abort 控制器(控制 stream 对话)
|
||||
const inputTimeout = ref<any>(); // 处理输入中回车的定时器
|
||||
const prompt = ref<string>(); // prompt
|
||||
const enableContext = ref<boolean>(true); // 是否开启上下文
|
||||
const enableWebSearch = ref<boolean>(false); // 是否开启联网搜索
|
||||
const uploadFiles = ref<string[]>([]); // 上传的文件 URL 列表
|
||||
// 接收 Stream 消息
|
||||
const receiveMessageFullText = ref('');
|
||||
const receiveMessageDisplayedText = ref('');
|
||||
|
||||
// =========== 【聊天对话】相关 ===========
|
||||
|
||||
/** 获取对话信息 */
|
||||
async function getConversation(id: null | number) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const conversation: AiChatConversationApi.ChatConversation =
|
||||
await getChatConversationMy(id);
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
activeConversation.value = conversation;
|
||||
activeConversationId.value = conversation.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击某个对话
|
||||
*
|
||||
* @param conversation 选中的对话
|
||||
* @return 是否切换成功
|
||||
*/
|
||||
async function handleConversationClick(
|
||||
conversation: AiChatConversationApi.ChatConversation,
|
||||
) {
|
||||
// 对话进行中,不允许切换
|
||||
if (conversationInProgress.value) {
|
||||
await alert('对话中,不允许切换!');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新选中的对话 id
|
||||
activeConversationId.value = conversation.id;
|
||||
activeConversation.value = conversation;
|
||||
// 刷新 message 列表
|
||||
await getMessageList();
|
||||
// 滚动底部
|
||||
await scrollToBottom(true);
|
||||
prompt.value = '';
|
||||
// 清空输入框
|
||||
prompt.value = '';
|
||||
// 清空文件列表
|
||||
uploadFiles.value = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 删除某个对话*/
|
||||
async function handlerConversationDelete(
|
||||
delConversation: AiChatConversationApi.ChatConversation,
|
||||
) {
|
||||
// 删除的对话如果是当前选中的,那么就重置
|
||||
if (activeConversationId.value === delConversation.id) {
|
||||
await handleConversationClear();
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空选中的对话 */
|
||||
async function handleConversationClear() {
|
||||
// 对话进行中,不允许切换
|
||||
if (conversationInProgress.value) {
|
||||
await alert('对话中,不允许切换!');
|
||||
return false;
|
||||
}
|
||||
activeConversationId.value = null;
|
||||
activeConversation.value = null;
|
||||
activeMessageList.value = [];
|
||||
// 清空输入框和文件列表
|
||||
prompt.value = '';
|
||||
uploadFiles.value = [];
|
||||
}
|
||||
|
||||
async function openChatConversationUpdateForm() {
|
||||
formModalApi.setData({ id: activeConversationId.value }).open();
|
||||
}
|
||||
|
||||
/** 对话更新成功,刷新最新信息 */
|
||||
async function handleConversationUpdateSuccess() {
|
||||
await getConversation(activeConversationId.value);
|
||||
}
|
||||
|
||||
/** 处理聊天对话的创建成功 */
|
||||
async function handleConversationCreate() {
|
||||
// 创建对话
|
||||
await conversationListRef.value.createConversation();
|
||||
}
|
||||
|
||||
/** 处理聊天对话的创建成功 */
|
||||
async function handleConversationCreateSuccess() {
|
||||
// 创建新的对话,清空输入框
|
||||
prompt.value = '';
|
||||
// 清空文件列表
|
||||
uploadFiles.value = [];
|
||||
}
|
||||
|
||||
// =========== 【消息列表】相关 ===========
|
||||
|
||||
/** 获取消息 message 列表 */
|
||||
async function getMessageList() {
|
||||
try {
|
||||
if (activeConversationId.value === null) {
|
||||
return;
|
||||
}
|
||||
// Timer 定时器,如果加载速度很快,就不进入加载中
|
||||
activeMessageListLoadingTimer.value = setTimeout(() => {
|
||||
activeMessageListLoading.value = true;
|
||||
}, 60);
|
||||
|
||||
// 获取消息列表
|
||||
activeMessageList.value = await getChatMessageListByConversationId(
|
||||
activeConversationId.value,
|
||||
);
|
||||
|
||||
// 滚动到最下面
|
||||
await nextTick();
|
||||
await scrollToBottom();
|
||||
} finally {
|
||||
// time 定时器,如果加载速度很快,就不进入加载中
|
||||
if (activeMessageListLoadingTimer.value) {
|
||||
clearTimeout(activeMessageListLoadingTimer.value);
|
||||
}
|
||||
// 加载结束
|
||||
activeMessageListLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息列表
|
||||
*
|
||||
* 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去
|
||||
*/
|
||||
const messageList = computed(() => {
|
||||
if (activeMessageList.value.length > 0) {
|
||||
return activeMessageList.value;
|
||||
}
|
||||
// 没有消息时,如果有 systemMessage 则展示它
|
||||
if (activeConversation.value?.systemMessage) {
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
type: 'system',
|
||||
content: activeConversation.value.systemMessage,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
/** 处理删除 message 消息 */
|
||||
function handleMessageDelete() {
|
||||
if (conversationInProgress.value) {
|
||||
alert('回答中,不能删除!');
|
||||
return;
|
||||
}
|
||||
// 刷新 message 列表
|
||||
getMessageList();
|
||||
}
|
||||
|
||||
/** 处理 message 清空 */
|
||||
async function handlerMessageClear() {
|
||||
if (!activeConversationId.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 确认提示
|
||||
await confirm('确认清空对话消息?');
|
||||
// 清空对话
|
||||
await deleteByConversationId(activeConversationId.value);
|
||||
// 刷新 message 列表
|
||||
activeMessageList.value = [];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 回到 message 列表的顶部 */
|
||||
function handleGoTopMessage() {
|
||||
messageRef.value.handlerGoTop();
|
||||
}
|
||||
|
||||
// =========== 【发送消息】相关 ===========
|
||||
|
||||
/** 处理来自 keydown 的发送消息 */
|
||||
async function handleSendByKeydown(event: any) {
|
||||
// 判断用户是否在输入
|
||||
if (isComposing.value) {
|
||||
return;
|
||||
}
|
||||
// 进行中不允许发送
|
||||
if (conversationInProgress.value) {
|
||||
return;
|
||||
}
|
||||
const content = prompt.value?.trim() as string;
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) {
|
||||
// 插入换行
|
||||
prompt.value += '\r\n';
|
||||
event.preventDefault(); // 防止默认的换行行为
|
||||
} else {
|
||||
// 发送消息
|
||||
await doSendMessage(content);
|
||||
event.preventDefault(); // 防止默认的提交行为
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理来自【发送】按钮的发送消息 */
|
||||
function handleSendByButton() {
|
||||
doSendMessage(prompt.value?.trim() as string);
|
||||
}
|
||||
|
||||
/** 处理 prompt 输入变化 */
|
||||
function handlePromptInput(event: any) {
|
||||
// 非输入法 输入设置为 true
|
||||
if (!isComposing.value) {
|
||||
// 回车 event data 是 null
|
||||
if (event.data === null || event.data === 'null') {
|
||||
return;
|
||||
}
|
||||
isComposing.value = true;
|
||||
}
|
||||
// 清理定时器
|
||||
if (inputTimeout.value) {
|
||||
clearTimeout(inputTimeout.value);
|
||||
}
|
||||
// 重置定时器
|
||||
inputTimeout.value = setTimeout(() => {
|
||||
isComposing.value = false;
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function onCompositionstart() {
|
||||
isComposing.value = true;
|
||||
}
|
||||
|
||||
function onCompositionend() {
|
||||
setTimeout(() => {
|
||||
isComposing.value = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** 真正执行【发送】消息操作 */
|
||||
async function doSendMessage(content: string) {
|
||||
// 校验
|
||||
if (content.length === 0) {
|
||||
ElMessage.error('发送失败,原因:内容为空!');
|
||||
return;
|
||||
}
|
||||
if (activeConversationId.value === null) {
|
||||
ElMessage.error('还没创建对话,不能发送!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 准备附件 URL 数组
|
||||
const attachmentUrls = [...uploadFiles.value];
|
||||
|
||||
// 清空输入框和文件列表
|
||||
prompt.value = '';
|
||||
uploadFiles.value = [];
|
||||
|
||||
// 执行发送
|
||||
await doSendMessageStream({
|
||||
conversationId: activeConversationId.value,
|
||||
content,
|
||||
attachmentUrls,
|
||||
} as AiChatMessageApi.ChatMessage);
|
||||
}
|
||||
|
||||
/** 真正执行【发送】消息操作 */
|
||||
async function doSendMessageStream(userMessage: AiChatMessageApi.ChatMessage) {
|
||||
// 创建 AbortController 实例,以便中止请求
|
||||
conversationInAbortController.value = new AbortController();
|
||||
// 标记对话进行中
|
||||
conversationInProgress.value = true;
|
||||
// 设置为空
|
||||
receiveMessageFullText.value = '';
|
||||
|
||||
try {
|
||||
// 1.1 先添加两个假数据,等 stream 返回再替换
|
||||
activeMessageList.value.push(
|
||||
{
|
||||
id: -1,
|
||||
conversationId: activeConversationId.value,
|
||||
type: 'user',
|
||||
content: userMessage.content,
|
||||
attachmentUrls: userMessage.attachmentUrls || [],
|
||||
createTime: new Date(),
|
||||
} as AiChatMessageApi.ChatMessage,
|
||||
{
|
||||
id: -2,
|
||||
conversationId: activeConversationId.value,
|
||||
type: 'assistant',
|
||||
content: '思考中...',
|
||||
reasoningContent: '',
|
||||
createTime: new Date(),
|
||||
} as AiChatMessageApi.ChatMessage,
|
||||
);
|
||||
// 1.2 滚动到最下面
|
||||
await nextTick();
|
||||
await scrollToBottom(); // 底部
|
||||
// 1.3 开始滚动
|
||||
textRoll().then();
|
||||
|
||||
// 2. 发送 event stream
|
||||
let isFirstChunk = true; // 是否是第一个 chunk 消息段
|
||||
await sendChatMessageStream(
|
||||
userMessage.conversationId,
|
||||
userMessage.content,
|
||||
conversationInAbortController.value,
|
||||
enableContext.value,
|
||||
enableWebSearch.value,
|
||||
async (res: any) => {
|
||||
const { code, data, msg } = JSON.parse(res.data);
|
||||
if (code !== 0) {
|
||||
await alert(`对话异常! ${msg}`);
|
||||
// 如果未接收到消息,则进行删除
|
||||
if (receiveMessageFullText.value === '') {
|
||||
activeMessageList.value.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果内容和推理内容都为空,就不处理
|
||||
if (data.receive.content === '' && !data.receive.reasoningContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 首次返回需要添加一个 message 到页面,后面的都是更新
|
||||
if (isFirstChunk) {
|
||||
isFirstChunk = false;
|
||||
// 弹出两个假数据
|
||||
activeMessageList.value.pop();
|
||||
activeMessageList.value.pop();
|
||||
// 更新返回的数据
|
||||
activeMessageList.value.push(data.send, data.receive);
|
||||
data.send.attachmentUrls = userMessage.attachmentUrls;
|
||||
}
|
||||
|
||||
// 处理 reasoningContent
|
||||
if (data.receive.reasoningContent) {
|
||||
const lastMessage =
|
||||
activeMessageList.value[activeMessageList.value.length - 1];
|
||||
// 累加推理内容
|
||||
lastMessage.reasoningContent =
|
||||
(lastMessage.reasoningContent || '') +
|
||||
data.receive.reasoningContent;
|
||||
}
|
||||
|
||||
// 处理正常内容
|
||||
if (data.receive.content !== '') {
|
||||
receiveMessageFullText.value =
|
||||
receiveMessageFullText.value + data.receive.content;
|
||||
}
|
||||
|
||||
// 滚动到最下面
|
||||
await scrollToBottom();
|
||||
},
|
||||
(error: any) => {
|
||||
// 异常提示,并停止流
|
||||
alert(`对话异常!`);
|
||||
stopStream();
|
||||
// 需要抛出异常,禁止重试
|
||||
throw error;
|
||||
},
|
||||
() => {
|
||||
stopStream();
|
||||
},
|
||||
userMessage.attachmentUrls,
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 停止 stream 流式调用 */
|
||||
async function stopStream() {
|
||||
// tip:如果 stream 进行中的 message,就需要调用 controller 结束
|
||||
if (conversationInAbortController.value) {
|
||||
conversationInAbortController.value.abort();
|
||||
}
|
||||
// 设置为 false
|
||||
conversationInProgress.value = false;
|
||||
}
|
||||
|
||||
/** 编辑 message:设置为 prompt,可以再次编辑 */
|
||||
function handleMessageEdit(message: AiChatMessageApi.ChatMessage) {
|
||||
prompt.value = message.content;
|
||||
}
|
||||
|
||||
/** 刷新 message:基于指定消息,再次发起对话 */
|
||||
function handleMessageRefresh(message: AiChatMessageApi.ChatMessage) {
|
||||
doSendMessage(message.content);
|
||||
}
|
||||
|
||||
// ============== 【消息滚动】相关 =============
|
||||
|
||||
/** 滚动到 message 底部 */
|
||||
async function scrollToBottom(isIgnore?: boolean) {
|
||||
await nextTick();
|
||||
if (messageRef.value) {
|
||||
messageRef.value.scrollToBottom(isIgnore);
|
||||
}
|
||||
}
|
||||
|
||||
/** 自提滚动效果 */
|
||||
async function textRoll() {
|
||||
let index = 0;
|
||||
try {
|
||||
// 只能执行一次
|
||||
if (textRoleRunning.value) {
|
||||
return;
|
||||
}
|
||||
// 设置状态
|
||||
textRoleRunning.value = true;
|
||||
receiveMessageDisplayedText.value = '';
|
||||
async function task() {
|
||||
// 调整速度
|
||||
const diff =
|
||||
(receiveMessageFullText.value.length -
|
||||
receiveMessageDisplayedText.value.length) /
|
||||
10;
|
||||
if (diff > 5) {
|
||||
textSpeed.value = 10;
|
||||
} else if (diff > 2) {
|
||||
textSpeed.value = 30;
|
||||
} else if (diff > 1.5) {
|
||||
textSpeed.value = 50;
|
||||
} else {
|
||||
textSpeed.value = 100;
|
||||
}
|
||||
// 对话结束,就按 30 的速度
|
||||
if (!conversationInProgress.value) {
|
||||
textSpeed.value = 10;
|
||||
}
|
||||
|
||||
if (index < receiveMessageFullText.value.length) {
|
||||
receiveMessageDisplayedText.value +=
|
||||
receiveMessageFullText.value[index];
|
||||
index++;
|
||||
|
||||
// 更新 message
|
||||
const lastMessage =
|
||||
activeMessageList.value[activeMessageList.value.length - 1];
|
||||
if (lastMessage)
|
||||
lastMessage.content = receiveMessageDisplayedText.value;
|
||||
// 滚动到住下面
|
||||
await scrollToBottom();
|
||||
// 重新设置任务
|
||||
timer = setTimeout(task, textSpeed.value);
|
||||
} else {
|
||||
// 不是对话中可以结束
|
||||
if (conversationInProgress.value) {
|
||||
// 重新设置任务
|
||||
timer = setTimeout(task, textSpeed.value);
|
||||
} else {
|
||||
textRoleRunning.value = false;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
let timer = setTimeout(task, textSpeed.value);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
// 如果有 conversationId 参数,则默认选中
|
||||
if (route.query.conversationId) {
|
||||
const id = route.query.conversationId as unknown as number;
|
||||
activeConversationId.value = id;
|
||||
await getConversation(id);
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
activeMessageListLoading.value = true;
|
||||
await getMessageList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<ElContainer class="absolute left-0 top-0 m-4 h-full w-full flex-1">
|
||||
<!-- 左侧:对话列表 -->
|
||||
<ConversationList
|
||||
class="!bg-card"
|
||||
:active-id="activeConversationId"
|
||||
ref="conversationListRef"
|
||||
@on-conversation-create="handleConversationCreateSuccess"
|
||||
@on-conversation-click="handleConversationClick"
|
||||
@on-conversation-clear="handleConversationClear"
|
||||
@on-conversation-delete="handlerConversationDelete"
|
||||
/>
|
||||
|
||||
<!-- 右侧:详情部分 -->
|
||||
<ElContainer class="bg-card mx-4">
|
||||
<ElHeader
|
||||
class="!bg-card border-border flex !h-12 items-center justify-between border-b !px-4"
|
||||
>
|
||||
<div class="text-lg font-bold">
|
||||
{{ activeConversation?.title ? activeConversation?.title : '对话' }}
|
||||
<span v-if="activeMessageList.length > 0">
|
||||
({{ activeMessageList.length }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex w-72 justify-end" v-if="activeConversation">
|
||||
<ElButton
|
||||
type="primary"
|
||||
plain
|
||||
class="mr-2 px-2"
|
||||
size="small"
|
||||
@click="openChatConversationUpdateForm"
|
||||
>
|
||||
<span v-html="activeConversation?.modelName"></span>
|
||||
<IconifyIcon icon="lucide:settings" class="ml-2 size-4" />
|
||||
</ElButton>
|
||||
<ElButton
|
||||
size="small"
|
||||
class="mr-2 px-2"
|
||||
@click="handlerMessageClear"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash-2" color="#787878" />
|
||||
</ElButton>
|
||||
<ElButton size="small" class="mr-2 px-2">
|
||||
<IconifyIcon icon="lucide:download" color="#787878" />
|
||||
</ElButton>
|
||||
<ElButton
|
||||
size="small"
|
||||
class="mr-2 px-2"
|
||||
@click="handleGoTopMessage"
|
||||
>
|
||||
<IconifyIcon icon="lucide:arrow-up" color="#787878" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElHeader>
|
||||
|
||||
<ElMain class="relative m-0 h-full w-full p-0">
|
||||
<div class="absolute inset-0 m-0 overflow-y-hidden p-0">
|
||||
<MessageLoading v-if="activeMessageListLoading" />
|
||||
<MessageNewConversation
|
||||
v-if="!activeConversation"
|
||||
@on-new-conversation="handleConversationCreate"
|
||||
/>
|
||||
<MessageListEmpty
|
||||
v-if="
|
||||
!activeMessageListLoading &&
|
||||
messageList.length === 0 &&
|
||||
activeConversation
|
||||
"
|
||||
@on-prompt="doSendMessage"
|
||||
/>
|
||||
<MessageList
|
||||
v-if="!activeMessageListLoading && messageList.length > 0"
|
||||
ref="messageRef"
|
||||
:conversation="activeConversation as any"
|
||||
:list="messageList as any"
|
||||
@on-delete-success="handleMessageDelete"
|
||||
@on-edit="handleMessageEdit"
|
||||
@on-refresh="handleMessageRefresh"
|
||||
/>
|
||||
</div>
|
||||
</ElMain>
|
||||
|
||||
<ElFooter class="!bg-card flex flex-col !p-0">
|
||||
<form
|
||||
class="border-border mx-4 mb-8 mt-2 flex flex-col rounded-xl border p-2"
|
||||
>
|
||||
<textarea
|
||||
class="box-border h-24 resize-none overflow-auto rounded-md p-2 focus:outline-none"
|
||||
v-model="prompt"
|
||||
@keydown="handleSendByKeydown"
|
||||
@input="handlePromptInput"
|
||||
@compositionstart="onCompositionstart"
|
||||
@compositionend="onCompositionend"
|
||||
placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"
|
||||
></textarea>
|
||||
<div class="flex justify-between pb-0 pt-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<MessageFileUpload
|
||||
v-model="uploadFiles"
|
||||
:disabled="conversationInProgress"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<ElSwitch v-model="enableContext" size="small" />
|
||||
<span class="ml-1 text-sm text-gray-400">上下文</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ElSwitch v-model="enableWebSearch" size="small" />
|
||||
<span class="ml-1 text-sm text-gray-400">联网搜索</span>
|
||||
</div>
|
||||
</div>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="handleSendByButton"
|
||||
:loading="conversationInProgress"
|
||||
v-if="conversationInProgress === false"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="
|
||||
conversationInProgress
|
||||
? 'lucide:loader'
|
||||
: 'lucide:send-horizontal'
|
||||
"
|
||||
/>
|
||||
{{ conversationInProgress ? '进行中' : '发送' }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="danger"
|
||||
@click="stopStream()"
|
||||
v-if="conversationInProgress === true"
|
||||
>
|
||||
<IconifyIcon icon="lucide:circle-stop" />
|
||||
停止
|
||||
</ElButton>
|
||||
</div>
|
||||
</form>
|
||||
</ElFooter>
|
||||
</ElContainer>
|
||||
</ElContainer>
|
||||
<FormModal @success="handleConversationUpdateSuccess" />
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,397 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
|
||||
|
||||
import { h, onMounted, ref, toRefs, watch } from 'vue';
|
||||
|
||||
import { confirm, prompt, useVbenDrawer } from '@vben/common-ui';
|
||||
import { IconifyIcon, SvgGptIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElAside,
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElEmpty,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
createChatConversationMy,
|
||||
deleteChatConversationMy,
|
||||
deleteChatConversationMyByUnpinned,
|
||||
getChatConversationMyList,
|
||||
updateChatConversationMy,
|
||||
} from '#/api/ai/chat/conversation';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import RoleRepository from '../role/repository.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeId: {
|
||||
type: [Number, null] as PropType<null | number>,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits([
|
||||
'onConversationCreate',
|
||||
'onConversationClick',
|
||||
'onConversationClear',
|
||||
'onConversationDelete',
|
||||
]);
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
connectedComponent: RoleRepository,
|
||||
});
|
||||
|
||||
const searchName = ref<string>(''); // 对话搜索
|
||||
const activeConversationId = ref<null | number>(null); // 选中的对话,默认为 null
|
||||
const hoverConversationId = ref<null | number>(null); // 悬浮上去的对话
|
||||
const conversationList = ref([] as AiChatConversationApi.ChatConversation[]); // 对话列表
|
||||
const conversationMap = ref<any>({}); // 对话分组 (置顶、今天、三天前、一星期前、一个月前)
|
||||
const loading = ref<boolean>(false); // 加载中
|
||||
const loadingTime = ref<any>();
|
||||
|
||||
/** 搜索对话 */
|
||||
async function searchConversation() {
|
||||
// 恢复数据
|
||||
if (searchName.value.trim().length === 0) {
|
||||
conversationMap.value = await getConversationGroupByCreateTime(
|
||||
conversationList.value,
|
||||
);
|
||||
} else {
|
||||
// 过滤
|
||||
const filterValues = conversationList.value.filter((item) => {
|
||||
return item.title.includes(searchName.value.trim());
|
||||
});
|
||||
conversationMap.value =
|
||||
await getConversationGroupByCreateTime(filterValues);
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击对话 */
|
||||
async function handleConversationClick(id: number) {
|
||||
// 过滤出选中的对话
|
||||
const filterConversation = conversationList.value.find((item) => {
|
||||
return item.id === id;
|
||||
});
|
||||
// 回调 onConversationClick
|
||||
// noinspection JSVoidFunctionReturnValueUsed
|
||||
const success = emits('onConversationClick', filterConversation) as any;
|
||||
// 切换对话
|
||||
if (success) {
|
||||
activeConversationId.value = id;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取对话列表 */
|
||||
async function getChatConversationList() {
|
||||
try {
|
||||
// 加载中
|
||||
loadingTime.value = setTimeout(() => {
|
||||
loading.value = true;
|
||||
}, 50);
|
||||
|
||||
// 1.1 获取 对话数据
|
||||
conversationList.value = await getChatConversationMyList();
|
||||
// 1.2 排序
|
||||
conversationList.value.sort((a, b) => {
|
||||
return Number(b.createTime) - Number(a.createTime);
|
||||
});
|
||||
// 1.3 没有任何对话情况
|
||||
if (conversationList.value.length === 0) {
|
||||
activeConversationId.value = null;
|
||||
conversationMap.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30 天前)
|
||||
conversationMap.value = await getConversationGroupByCreateTime(
|
||||
conversationList.value,
|
||||
);
|
||||
} finally {
|
||||
// 清理定时器
|
||||
if (loadingTime.value) {
|
||||
clearTimeout(loadingTime.value);
|
||||
}
|
||||
// 加载完成
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按照 creteTime 创建时间,进行分组 */
|
||||
async function getConversationGroupByCreateTime(
|
||||
list: AiChatConversationApi.ChatConversation[],
|
||||
) {
|
||||
// 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前)
|
||||
// noinspection NonAsciiCharacters
|
||||
const groupMap: any = {
|
||||
置顶: [],
|
||||
今天: [],
|
||||
一天前: [],
|
||||
三天前: [],
|
||||
七天前: [],
|
||||
三十天前: [],
|
||||
};
|
||||
// 当前时间的时间戳
|
||||
const now = Date.now();
|
||||
// 定义时间间隔常量(单位:毫秒)
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const threeDays = 3 * oneDay;
|
||||
const sevenDays = 7 * oneDay;
|
||||
const thirtyDays = 30 * oneDay;
|
||||
for (const conversation of list) {
|
||||
// 置顶
|
||||
if (conversation.pinned) {
|
||||
groupMap['置顶'].push(conversation);
|
||||
continue;
|
||||
}
|
||||
// 计算时间差(单位:毫秒)
|
||||
const diff = now - Number(conversation.createTime);
|
||||
// 根据时间间隔判断
|
||||
if (diff < oneDay) {
|
||||
groupMap['今天'].push(conversation);
|
||||
} else if (diff < threeDays) {
|
||||
groupMap['一天前'].push(conversation);
|
||||
} else if (diff < sevenDays) {
|
||||
groupMap['三天前'].push(conversation);
|
||||
} else if (diff < thirtyDays) {
|
||||
groupMap['七天前'].push(conversation);
|
||||
} else {
|
||||
groupMap['三十天前'].push(conversation);
|
||||
}
|
||||
}
|
||||
return groupMap;
|
||||
}
|
||||
|
||||
/** 新建对话 */
|
||||
async function handleConversationCreate() {
|
||||
// 1. 创建对话
|
||||
const conversationId = await createChatConversationMy({
|
||||
roleId: undefined,
|
||||
} as unknown as AiChatConversationApi.ChatConversation);
|
||||
|
||||
// 2. 刷新列表
|
||||
await getChatConversationList();
|
||||
|
||||
// 3. 回调
|
||||
emits('onConversationCreate', conversationId);
|
||||
}
|
||||
|
||||
/** 清空未置顶的对话 */
|
||||
async function handleConversationClear() {
|
||||
await confirm({
|
||||
title: '清空未置顶的对话',
|
||||
content: h('div', {}, [
|
||||
h('p', '确认清空未置顶的对话吗?'),
|
||||
h('p', '清空后,未置顶的对话将被删除,无法恢复!'),
|
||||
]),
|
||||
});
|
||||
// 清空
|
||||
await deleteChatConversationMyByUnpinned();
|
||||
// 刷新列表
|
||||
await getChatConversationList();
|
||||
// 回调
|
||||
emits('onConversationClear');
|
||||
}
|
||||
|
||||
/** 删除对话 */
|
||||
async function handleConversationDelete(id: number) {
|
||||
await confirm({
|
||||
title: '删除对话',
|
||||
content: h('div', {}, [
|
||||
h('p', '确认删除该对话吗?'),
|
||||
h('p', '删除后,该对话将被删除,无法恢复!'),
|
||||
]),
|
||||
});
|
||||
// 删除
|
||||
await deleteChatConversationMy(id);
|
||||
// 刷新列表
|
||||
await getChatConversationList();
|
||||
// 回调
|
||||
emits('onConversationDelete', id);
|
||||
}
|
||||
|
||||
/** 置顶对话 */
|
||||
async function handleConversationPin(conversation: any) {
|
||||
// 更新
|
||||
await updateChatConversationMy({
|
||||
id: conversation.id,
|
||||
pinned: !conversation.pinned,
|
||||
} as AiChatConversationApi.ChatConversation);
|
||||
// 刷新列表
|
||||
await getChatConversationList();
|
||||
}
|
||||
|
||||
/** 编辑对话 */
|
||||
async function handleConversationEdit(conversation: any) {
|
||||
const title = await prompt({
|
||||
title: '编辑对话',
|
||||
content: '请输入对话标题',
|
||||
defaultValue: conversation.title,
|
||||
});
|
||||
// 更新
|
||||
await updateChatConversationMy({
|
||||
id: conversation.id,
|
||||
title,
|
||||
} as AiChatConversationApi.ChatConversation);
|
||||
// 刷新列表
|
||||
await getChatConversationList();
|
||||
// 提示
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
}
|
||||
|
||||
/** 打开角色仓库 */
|
||||
async function handleRoleRepositoryOpen() {
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
/** 监听 activeId 变化 */
|
||||
watch(
|
||||
() => props.activeId,
|
||||
(newValue) => {
|
||||
activeConversationId.value = newValue;
|
||||
},
|
||||
);
|
||||
|
||||
const { activeId } = toRefs(props);
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
// 获取对话列表
|
||||
await getChatConversationList();
|
||||
// 设置选中的对话
|
||||
if (activeId.value) {
|
||||
activeConversationId.value = activeId.value;
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ getChatConversationList });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElAside
|
||||
class="bg-card relative flex h-full flex-col overflow-hidden border-r border-gray-200"
|
||||
width="280px"
|
||||
>
|
||||
<Drawer />
|
||||
<!-- 头部 -->
|
||||
<div class="flex flex-col p-4">
|
||||
<div class="mb-4 flex flex-row items-center justify-between">
|
||||
<div class="text-lg font-bold">对话</div>
|
||||
<div class="flex flex-row">
|
||||
<ElButton
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click="handleConversationCreate"
|
||||
>
|
||||
<IconifyIcon icon="lucide:plus" />
|
||||
</ElButton>
|
||||
<ElButton
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click="handleConversationClear"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash" />
|
||||
</ElButton>
|
||||
<ElButton
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click="handleRoleRepositoryOpen"
|
||||
>
|
||||
<IconifyIcon icon="lucide:user" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElInput
|
||||
v-model="searchName"
|
||||
placeholder="搜索对话"
|
||||
@keyup.enter="searchConversation"
|
||||
>
|
||||
<template #suffix>
|
||||
<IconifyIcon icon="lucide:search" />
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<!-- 对话列表 -->
|
||||
<div class="flex-1 overflow-y-auto px-4">
|
||||
<div v-if="loading" class="flex h-full items-center justify-center">
|
||||
<div class="text-sm text-gray-400">加载中...</div>
|
||||
</div>
|
||||
<div v-else-if="Object.keys(conversationMap).length === 0">
|
||||
<ElEmpty description="暂无对话" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="(conversations, groupName) in conversationMap"
|
||||
:key="groupName"
|
||||
>
|
||||
<div
|
||||
v-if="conversations.length > 0"
|
||||
class="mb-2 mt-4 text-xs text-gray-400"
|
||||
>
|
||||
{{ groupName }}
|
||||
</div>
|
||||
<div
|
||||
v-for="conversation in conversations"
|
||||
:key="conversation.id"
|
||||
class="group relative mb-2 cursor-pointer rounded-lg p-2 transition-all hover:bg-gray-100"
|
||||
:class="{
|
||||
'bg-gray-100': activeConversationId === conversation.id,
|
||||
}"
|
||||
@click="handleConversationClick(conversation.id)"
|
||||
@mouseenter="hoverConversationId = conversation.id"
|
||||
@mouseleave="hoverConversationId = null"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<ElAvatar
|
||||
v-if="conversation.roleAvatar"
|
||||
:src="conversation.roleAvatar"
|
||||
:size="28"
|
||||
/>
|
||||
<SvgGptIcon v-else class="size-7" />
|
||||
<div class="ml-2 flex-1 overflow-hidden">
|
||||
<div class="truncate text-sm font-medium">
|
||||
{{ conversation.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hoverConversationId === conversation.id"
|
||||
class="flex flex-row"
|
||||
>
|
||||
<ElButton
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click.stop="handleConversationPin(conversation)"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="
|
||||
conversation.pinned ? 'lucide:pin-off' : 'lucide:pin'
|
||||
"
|
||||
/>
|
||||
</ElButton>
|
||||
<ElButton
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click.stop="handleConversationEdit(conversation)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:edit" />
|
||||
</ElButton>
|
||||
<ElButton
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click.stop="handleConversationDelete(conversation.id)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElAside>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
getChatConversationMy,
|
||||
updateChatConversationMy,
|
||||
} from '#/api/ai/chat/conversation';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<AiChatConversationApi.ChatConversation>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 110,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data =
|
||||
(await formApi.getValues()) as AiChatConversationApi.ChatConversation;
|
||||
try {
|
||||
await updateChatConversationMy(data);
|
||||
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<AiChatConversationApi.ChatConversation>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getChatConversationMy(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-2/5" title="设定">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,304 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatFileSize, getFileIcon } from '@vben/utils';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { useUpload } from '#/components/upload/use-upload';
|
||||
|
||||
export interface FileItem {
|
||||
name: string;
|
||||
size: number;
|
||||
url?: string;
|
||||
uploading?: boolean;
|
||||
progress?: number;
|
||||
raw?: File;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
acceptTypes?: string;
|
||||
disabled?: boolean;
|
||||
limit?: number;
|
||||
maxSize?: number;
|
||||
modelValue?: string[];
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
limit: 5,
|
||||
maxSize: 10,
|
||||
acceptTypes:
|
||||
'.jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.txt,.xls,.xlsx,.ppt,.pptx,.csv,.md',
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]];
|
||||
uploadError: [error: any];
|
||||
uploadSuccess: [file: FileItem];
|
||||
}>();
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
const fileList = ref<FileItem[]>([]);
|
||||
const uploadedUrls = ref<string[]>([]);
|
||||
const showTooltip = ref(false);
|
||||
const hideTimer = ref<NodeJS.Timeout | null>(null);
|
||||
const { httpRequest } = useUpload();
|
||||
|
||||
/** 监听 v-model 变化 */
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
uploadedUrls.value = [...newVal];
|
||||
if (newVal.length === 0) {
|
||||
fileList.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
/** 是否有文件 */
|
||||
const hasFiles = computed(() => fileList.value.length > 0);
|
||||
|
||||
/** 是否达到上传限制 */
|
||||
const isLimitReached = computed(() => fileList.value.length >= props.limit);
|
||||
|
||||
/** 触发文件选择 */
|
||||
function triggerFileInput() {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
/** 显示 tooltip */
|
||||
function showTooltipHandler() {
|
||||
if (hideTimer.value) {
|
||||
clearTimeout(hideTimer.value);
|
||||
hideTimer.value = null;
|
||||
}
|
||||
showTooltip.value = true;
|
||||
}
|
||||
|
||||
/** 隐藏 tooltip */
|
||||
function hideTooltipHandler() {
|
||||
hideTimer.value = setTimeout(() => {
|
||||
showTooltip.value = false;
|
||||
hideTimer.value = null;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/** 处理文件选择 */
|
||||
async function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = [...(target.files || [])];
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length + fileList.value.length > props.limit) {
|
||||
ElMessage.error(`最多只能上传 ${props.limit} 个文件`);
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
ElMessage.error(`文件 ${file.name} 大小超过 ${props.maxSize}MB`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileItem: FileItem = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
uploading: true,
|
||||
progress: 0,
|
||||
raw: file,
|
||||
};
|
||||
fileList.value.push(fileItem);
|
||||
await uploadFile(fileItem);
|
||||
}
|
||||
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
/** 上传文件 */
|
||||
async function uploadFile(fileItem: FileItem) {
|
||||
try {
|
||||
const progressInterval = setInterval(() => {
|
||||
if (fileItem.progress! < 90) {
|
||||
fileItem.progress = (fileItem.progress || 0) + Math.random() * 10;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
const response = await httpRequest(fileItem.raw!);
|
||||
clearInterval(progressInterval);
|
||||
|
||||
fileItem.uploading = false;
|
||||
fileItem.progress = 100;
|
||||
|
||||
// 调试日志
|
||||
console.log('上传响应:', response);
|
||||
|
||||
// 兼容不同的返回格式:{ url: '...' } 或 { data: '...' } 或直接是字符串
|
||||
const fileUrl =
|
||||
(response as any)?.url || (response as any)?.data || response;
|
||||
fileItem.url = fileUrl;
|
||||
|
||||
console.log('提取的文件 URL:', fileUrl);
|
||||
|
||||
// 只有当 URL 有效时才添加到列表
|
||||
if (fileUrl && typeof fileUrl === 'string') {
|
||||
uploadedUrls.value.push(fileUrl);
|
||||
emit('uploadSuccess', fileItem);
|
||||
updateModelValue();
|
||||
} else {
|
||||
throw new Error('上传返回的 URL 无效');
|
||||
}
|
||||
} catch (error) {
|
||||
fileItem.uploading = false;
|
||||
ElMessage.error(`文件 ${fileItem.name} 上传失败`);
|
||||
emit('uploadError', error);
|
||||
|
||||
const index = fileList.value.indexOf(fileItem);
|
||||
if (index !== -1) {
|
||||
removeFile(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除文件 */
|
||||
function removeFile(index: number) {
|
||||
const removedFile = fileList.value[index];
|
||||
fileList.value.splice(index, 1);
|
||||
if (removedFile?.url) {
|
||||
const urlIndex = uploadedUrls.value.indexOf(removedFile.url);
|
||||
if (urlIndex !== -1) {
|
||||
uploadedUrls.value.splice(urlIndex, 1);
|
||||
}
|
||||
}
|
||||
updateModelValue();
|
||||
}
|
||||
|
||||
/** 更新 v-model */
|
||||
function updateModelValue() {
|
||||
emit('update:modelValue', [...uploadedUrls.value]);
|
||||
}
|
||||
|
||||
/** 清空文件 */
|
||||
function clearFiles() {
|
||||
fileList.value = [];
|
||||
uploadedUrls.value = [];
|
||||
updateModelValue();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
triggerFileInput,
|
||||
clearFiles,
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (hideTimer.value) {
|
||||
clearTimeout(hideTimer.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!disabled"
|
||||
class="relative inline-block"
|
||||
@mouseenter="showTooltipHandler"
|
||||
@mouseleave="hideTooltipHandler"
|
||||
>
|
||||
<!-- 文件上传按钮 -->
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex h-8 w-8 items-center justify-center rounded-full border-0 bg-transparent text-gray-600 transition-all duration-200 hover:bg-gray-100"
|
||||
:class="{ 'text-blue-500 hover:bg-blue-50': hasFiles }"
|
||||
:disabled="isLimitReached"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<IconifyIcon icon="lucide:paperclip" :size="16" />
|
||||
<!-- 文件数量徽章 -->
|
||||
<span
|
||||
v-if="hasFiles"
|
||||
class="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white"
|
||||
>
|
||||
{{ fileList.length }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 隐藏的文件输入框 -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
style="display: none"
|
||||
:accept="acceptTypes"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- Hover 显示的文件列表 -->
|
||||
<div
|
||||
v-if="hasFiles && showTooltip"
|
||||
class="animate-in fade-in slide-in-from-bottom-1 absolute bottom-[calc(100%+8px)] left-1/2 z-[1000] min-w-[240px] max-w-[320px] -translate-x-1/2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg duration-200"
|
||||
@mouseenter="showTooltipHandler"
|
||||
@mouseleave="hideTooltipHandler"
|
||||
>
|
||||
<!-- Tooltip 箭头 -->
|
||||
<div
|
||||
class="absolute -bottom-[5px] left-1/2 h-0 w-0 -translate-x-1/2 border-l-[5px] border-r-[5px] border-t-[5px] border-l-transparent border-r-transparent border-t-gray-200"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-[1px] left-1/2 h-0 w-0 -translate-x-1/2 border-l-[4px] border-r-[4px] border-t-[4px] border-l-transparent border-r-transparent border-t-white"
|
||||
></div>
|
||||
</div>
|
||||
<!-- 文件列表 -->
|
||||
<div
|
||||
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-gray-300 hover:scrollbar-thumb-gray-400 scrollbar-thumb-rounded-sm max-h-[200px] overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="(file, index) in fileList"
|
||||
:key="index"
|
||||
class="mb-1 flex items-center justify-between rounded-md bg-gray-50 p-2 text-xs transition-all duration-200 last:mb-0 hover:bg-gray-100"
|
||||
:class="{ 'opacity-70': file.uploading }"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center">
|
||||
<IconifyIcon
|
||||
:icon="getFileIcon(file.name)"
|
||||
class="mr-2 flex-shrink-0 text-blue-500"
|
||||
/>
|
||||
<span
|
||||
class="mr-1 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-medium text-gray-900"
|
||||
>
|
||||
{{ file.name }}
|
||||
</span>
|
||||
<span class="flex-shrink-0 text-[11px] text-gray-500">
|
||||
({{ formatFileSize(file.size) }})
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-2 flex flex-shrink-0 items-center gap-1">
|
||||
<div
|
||||
v-if="file.uploading"
|
||||
class="h-1 w-[60px] overflow-hidden rounded-full bg-gray-200"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all duration-300"
|
||||
:style="{ width: `${file.progress || 0}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="!disabled"
|
||||
type="button"
|
||||
class="flex h-5 w-5 items-center justify-center rounded text-red-500 hover:bg-red-50"
|
||||
@click="removeFile(index)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:x" :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { getFileIcon, getFileNameFromUrl, getFileTypeClass } from '@vben/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
attachmentUrls?: string[];
|
||||
}>();
|
||||
|
||||
/** 过滤掉空值的附件列表 */
|
||||
const validAttachmentUrls = computed(() => {
|
||||
return (props.attachmentUrls || []).filter((url) => url && url.trim());
|
||||
});
|
||||
|
||||
/** 点击文件 */
|
||||
function handleFileClick(url: string) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="validAttachmentUrls.length > 0" class="mt-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(url, index) in validAttachmentUrls"
|
||||
:key="index"
|
||||
class="max-w-70 flex min-w-40 cursor-pointer items-center rounded-lg border border-transparent bg-gray-100 p-3 transition-all duration-200 hover:-translate-y-1 hover:bg-gray-200 hover:shadow-lg"
|
||||
@click="handleFileClick(url)"
|
||||
>
|
||||
<div class="mr-3 flex-shrink-0">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br font-bold text-white"
|
||||
:class="getFileTypeClass(getFileNameFromUrl(url))"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="getFileIcon(getFileNameFromUrl(url))"
|
||||
:size="20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="mb-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-tight text-gray-800"
|
||||
:title="getFileNameFromUrl(url)"
|
||||
>
|
||||
{{ getFileNameFromUrl(url) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { ElTooltip } from 'element-plus';
|
||||
|
||||
const props = defineProps<{
|
||||
segments: {
|
||||
content: string;
|
||||
documentId: number;
|
||||
documentName: string;
|
||||
id: number;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const document = ref<null | {
|
||||
id: number;
|
||||
segments: {
|
||||
content: string;
|
||||
id: number;
|
||||
}[];
|
||||
title: string;
|
||||
}>(null); // 知识库文档列表
|
||||
const dialogVisible = ref(false); // 知识引用详情弹窗
|
||||
const documentRef = ref<HTMLElement>(); // 知识引用详情弹窗 Ref
|
||||
|
||||
/** 按照 document 聚合 segments */
|
||||
const documentList = computed(() => {
|
||||
if (!props.segments) return [];
|
||||
|
||||
const docMap = new Map();
|
||||
props.segments.forEach((segment) => {
|
||||
if (!docMap.has(segment.documentId)) {
|
||||
docMap.set(segment.documentId, {
|
||||
id: segment.documentId,
|
||||
title: segment.documentName,
|
||||
segments: [],
|
||||
});
|
||||
}
|
||||
docMap.get(segment.documentId).segments.push({
|
||||
id: segment.id,
|
||||
content: segment.content,
|
||||
});
|
||||
});
|
||||
return [...docMap.values()];
|
||||
});
|
||||
|
||||
/** 点击 document 处理 */
|
||||
function handleClick(doc: any) {
|
||||
document.value = doc;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 知识引用列表 -->
|
||||
<div
|
||||
v-if="segments && segments.length > 0"
|
||||
class="mt-2 rounded-lg bg-gray-50 p-2"
|
||||
>
|
||||
<div class="mb-2 flex items-center text-sm text-gray-400">
|
||||
<IconifyIcon icon="lucide:file-text" class="mr-1" /> 知识引用
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(doc, index) in documentList"
|
||||
:key="index"
|
||||
class="bg-card cursor-pointer rounded-lg p-2 px-3 transition-all hover:bg-blue-50"
|
||||
@click="handleClick(doc)"
|
||||
>
|
||||
<div class="mb-1 text-sm text-gray-600">
|
||||
{{ doc.title }}
|
||||
<span class="ml-1 text-xs text-gray-300">
|
||||
({{ doc.segments.length }} 条)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElTooltip placement="top-start" trigger="click">
|
||||
<div ref="documentRef"></div>
|
||||
<template #content>
|
||||
<div class="mb-3 text-base font-bold">{{ document?.title }}</div>
|
||||
<div class="max-h-[60vh] overflow-y-auto">
|
||||
<div
|
||||
v-for="(segment, index) in document?.segments"
|
||||
:key="index"
|
||||
class="border-b-solid border-b-gray-200 p-3 last:border-b-0"
|
||||
>
|
||||
<div
|
||||
class="mb-2 block w-fit rounded-sm px-2 py-1 text-xs text-gray-400"
|
||||
>
|
||||
分段 {{ segment.id }}
|
||||
</div>
|
||||
<div class="mt-2 text-sm leading-[1.6] text-gray-600">
|
||||
{{ segment.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTooltip>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!-- 消息列表为空时,展示 prompt 列表 -->
|
||||
<script setup lang="ts">
|
||||
const emits = defineEmits(['onPrompt']);
|
||||
|
||||
const promptList = [
|
||||
{
|
||||
prompt: '今天气怎么样?',
|
||||
},
|
||||
{
|
||||
prompt: '写一首好听的诗歌?',
|
||||
},
|
||||
]; // prompt 列表
|
||||
|
||||
/** 选中 prompt 点击 */
|
||||
async function handlerPromptClick(prompt: any) {
|
||||
emits('onPrompt', prompt.prompt);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="relative flex h-full w-full flex-row justify-center">
|
||||
<!-- center-container -->
|
||||
<div class="flex flex-col justify-center">
|
||||
<!-- title -->
|
||||
<div class="text-center text-3xl font-bold">芋道 AI</div>
|
||||
|
||||
<!-- role-list -->
|
||||
<div class="mt-5 flex w-96 flex-wrap items-center justify-center">
|
||||
<div
|
||||
v-for="prompt in promptList"
|
||||
:key="prompt.prompt"
|
||||
@click="handlerPromptClick(prompt)"
|
||||
class="m-2.5 flex w-44 cursor-pointer justify-center rounded-lg border border-gray-200 leading-10 hover:bg-gray-100"
|
||||
>
|
||||
{{ prompt.prompt }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
242
apps/web-ele/src/views/ai/chat/index/modules/message/list.vue
Normal file
242
apps/web-ele/src/views/ai/chat/index/modules/message/list.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
|
||||
import type { AiChatMessageApi } from '#/api/ai/chat/message';
|
||||
|
||||
import { computed, nextTick, onMounted, ref, toRefs } from 'vue';
|
||||
|
||||
import { IconifyIcon, SvgGptIcon } from '@vben/icons';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { ElAvatar, ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
import { deleteChatMessage } from '#/api/ai/chat/message';
|
||||
import { MarkdownView } from '#/components/markdown-view';
|
||||
|
||||
import MessageFiles from './files.vue';
|
||||
import MessageKnowledge from './knowledge.vue';
|
||||
import MessageReasoning from './reasoning.vue';
|
||||
import MessageWebSearch from './web-search.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object as PropType<AiChatConversationApi.ChatConversation>,
|
||||
required: true,
|
||||
},
|
||||
list: {
|
||||
type: Array as PropType<AiChatMessageApi.ChatMessage[]>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']);
|
||||
const { copy } = useClipboard(); // 初始化 copy 到粘贴板
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 判断"消息列表"滚动的位置(用于判断是否需要滚动到消息最下方)
|
||||
const messageContainer: any = ref(null);
|
||||
const isScrolling = ref(false); // 用于判断用户是否在滚动
|
||||
|
||||
const userAvatar = computed(
|
||||
() => userStore.userInfo?.avatar || preferences.app.defaultAvatar,
|
||||
);
|
||||
|
||||
const { list } = toRefs(props); // 定义 emits
|
||||
|
||||
// ============ 处理对话滚动 ==============
|
||||
|
||||
/** 滚动到底部 */
|
||||
async function scrollToBottom(isIgnore?: boolean) {
|
||||
// 注意要使用 nextTick 以免获取不到 dom
|
||||
await nextTick();
|
||||
if (isIgnore || !isScrolling.value) {
|
||||
messageContainer.value.scrollTop =
|
||||
messageContainer.value.scrollHeight - messageContainer.value.offsetHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const scrollContainer = messageContainer.value;
|
||||
const scrollTop = scrollContainer.scrollTop;
|
||||
const scrollHeight = scrollContainer.scrollHeight;
|
||||
const offsetHeight = scrollContainer.offsetHeight;
|
||||
isScrolling.value = scrollTop + offsetHeight < scrollHeight - 100;
|
||||
}
|
||||
|
||||
/** 回到底部 */
|
||||
async function handleGoBottom() {
|
||||
const scrollContainer = messageContainer.value;
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}
|
||||
|
||||
/** 回到顶部 */
|
||||
async function handlerGoTop() {
|
||||
const scrollContainer = messageContainer.value;
|
||||
scrollContainer.scrollTop = 0;
|
||||
}
|
||||
|
||||
defineExpose({ scrollToBottom, handlerGoTop }); // 提供方法给 parent 调用
|
||||
|
||||
// ============ 处理消息操作 ==============
|
||||
|
||||
/** 复制 */
|
||||
async function copyContent(content: string) {
|
||||
await copy(content);
|
||||
ElMessage.success('复制成功!');
|
||||
}
|
||||
/** 删除 */
|
||||
async function handleDelete(id: number) {
|
||||
// 删除 message
|
||||
await deleteChatMessage(id);
|
||||
ElMessage.success('删除成功!');
|
||||
// 回调
|
||||
emits('onDeleteSuccess');
|
||||
}
|
||||
|
||||
/** 刷新 */
|
||||
async function handleRefresh(message: AiChatMessageApi.ChatMessage) {
|
||||
emits('onRefresh', message);
|
||||
}
|
||||
|
||||
/** 编辑 */
|
||||
async function handleEdit(message: AiChatMessageApi.ChatMessage) {
|
||||
emits('onEdit', message);
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
messageContainer.value.addEventListener('scroll', handleScroll);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div ref="messageContainer" class="relative h-full overflow-y-auto">
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="index"
|
||||
class="mt-12 flex flex-col overflow-y-hidden px-5"
|
||||
>
|
||||
<!-- 左侧消息:system、assistant -->
|
||||
<div v-if="item.type !== 'user'" class="flex flex-row">
|
||||
<div class="avatar">
|
||||
<ElAvatar
|
||||
v-if="conversation.roleAvatar"
|
||||
:src="conversation.roleAvatar"
|
||||
:size="28"
|
||||
/>
|
||||
<SvgGptIcon v-else class="size-7" />
|
||||
</div>
|
||||
<div class="mx-4 flex flex-col text-left">
|
||||
<div class="text-left leading-10">
|
||||
{{ formatDateTime(item.createTime) }}
|
||||
</div>
|
||||
<div
|
||||
class="relative flex flex-col break-words rounded-lg bg-gray-100 p-2.5 pb-1 pt-2.5 shadow-sm"
|
||||
>
|
||||
<MessageReasoning
|
||||
:reasoning-content="item.reasoningContent || ''"
|
||||
:content="item.content || ''"
|
||||
/>
|
||||
<MarkdownView
|
||||
class="text-sm text-gray-600"
|
||||
:content="item.content"
|
||||
/>
|
||||
<MessageFiles :attachment-urls="item.attachmentUrls" />
|
||||
<MessageKnowledge v-if="item.segments" :segments="item.segments" />
|
||||
<MessageWebSearch
|
||||
v-if="item.webSearchPages"
|
||||
:web-search-pages="item.webSearchPages"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-row">
|
||||
<ElButton
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click="copyContent(item.content)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:copy" />
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="item.id > 0"
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click="handleDelete(item.id)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧消息:user -->
|
||||
<div v-else class="flex flex-row-reverse justify-start">
|
||||
<div class="avatar">
|
||||
<ElAvatar :src="userAvatar" :size="28" />
|
||||
</div>
|
||||
<div class="mx-4 flex flex-col text-left">
|
||||
<div class="text-left leading-8">
|
||||
{{ formatDateTime(item.createTime) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.attachmentUrls && item.attachmentUrls.length > 0"
|
||||
class="mb-2 flex flex-row-reverse"
|
||||
>
|
||||
<MessageFiles :attachment-urls="item.attachmentUrls" />
|
||||
</div>
|
||||
<div class="flex flex-row-reverse">
|
||||
<div
|
||||
v-if="item.content && item.content.trim()"
|
||||
class="inline w-auto whitespace-pre-wrap break-words rounded-lg bg-blue-500 p-2.5 text-sm text-white shadow-sm"
|
||||
>
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-row-reverse">
|
||||
<ElButton
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click="copyContent(item.content)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:copy" />
|
||||
</ElButton>
|
||||
<ElButton
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click="handleDelete(item.id)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:trash" />
|
||||
</ElButton>
|
||||
<ElButton
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click="handleRefresh(item)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:refresh-cw" />
|
||||
</ElButton>
|
||||
<ElButton
|
||||
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
||||
text
|
||||
@click="handleEdit(item)"
|
||||
>
|
||||
<IconifyIcon icon="lucide:edit" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回到底部按钮 -->
|
||||
<div
|
||||
v-if="isScrolling"
|
||||
class="z-1000 absolute bottom-0 right-1/2"
|
||||
@click="handleGoBottom"
|
||||
>
|
||||
<ElButton circle>
|
||||
<IconifyIcon icon="lucide:chevron-down" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ElSkeleton } from 'element-plus';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8">
|
||||
<ElSkeleton :rows="5" animated />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ElButton } from 'element-plus';
|
||||
|
||||
const emits = defineEmits(['onNewConversation']);
|
||||
|
||||
/** 新建 conversation 聊天对话 */
|
||||
function handlerNewChat() {
|
||||
emits('onNewConversation');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-row justify-center">
|
||||
<div class="flex flex-col justify-center">
|
||||
<div class="text-center text-sm text-gray-400">
|
||||
点击下方按钮,开始你的对话吧
|
||||
</div>
|
||||
<div class="mt-5 flex flex-row justify-center">
|
||||
<ElButton type="primary" round @click="handlerNewChat">
|
||||
新建对话
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { MarkdownView } from '#/components/markdown-view';
|
||||
|
||||
const props = defineProps<{
|
||||
content?: string;
|
||||
reasoningContent?: string;
|
||||
}>();
|
||||
|
||||
const isExpanded = ref(true); // 默认展开
|
||||
|
||||
/** 判断是否应该显示组件 */
|
||||
const shouldShowComponent = computed(() => {
|
||||
return props.reasoningContent && props.reasoningContent.trim() !== '';
|
||||
});
|
||||
|
||||
/** 标题文本 */
|
||||
const titleText = computed(() => {
|
||||
const hasReasoningContent =
|
||||
props.reasoningContent && props.reasoningContent.trim() !== '';
|
||||
const hasContent = props.content && props.content.trim() !== '';
|
||||
if (hasReasoningContent && !hasContent) {
|
||||
return '深度思考中';
|
||||
}
|
||||
return '已深度思考';
|
||||
});
|
||||
|
||||
/** 切换展开/收起 */
|
||||
function toggleExpanded() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shouldShowComponent" class="mt-2.5">
|
||||
<!-- 标题栏 -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-t-lg border border-b-0 border-gray-200/60 bg-gradient-to-r from-blue-50 to-purple-50 p-2 transition-all duration-200 hover:from-blue-100 hover:to-purple-100"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 text-sm font-medium text-gray-700">
|
||||
<IconifyIcon icon="lucide:brain" class="text-blue-600" :size="16" />
|
||||
<span>{{ titleText }}</span>
|
||||
</div>
|
||||
<IconifyIcon
|
||||
icon="lucide:chevron-down"
|
||||
class="text-gray-500 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
:size="14"
|
||||
/>
|
||||
</div>
|
||||
<!-- 内容区 -->
|
||||
<div
|
||||
v-show="isExpanded"
|
||||
class="scrollbar-thin max-h-[300px] overflow-y-auto rounded-b-lg border border-t-0 border-gray-200/60 bg-white/70 p-3 shadow-sm backdrop-blur-sm"
|
||||
>
|
||||
<MarkdownView
|
||||
v-if="props.reasoningContent"
|
||||
class="text-sm leading-relaxed text-gray-700"
|
||||
:content="props.reasoningContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义滚动条 */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
@apply rounded-sm bg-gray-400/40;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400/60;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import type { AiChatMessageApi } from '#/api/ai/chat/message';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
defineProps<{
|
||||
webSearchPages?: AiChatMessageApi.WebSearchPage[];
|
||||
}>();
|
||||
|
||||
const isExpanded = ref(false); // 默认收起
|
||||
const selectedResult = ref<AiChatMessageApi.WebSearchPage | null>(null); // 选中的搜索结果
|
||||
const iconLoadError = ref<Record<number, boolean>>({}); // 记录图标加载失败
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: '联网搜索详情',
|
||||
closable: true,
|
||||
footer: true,
|
||||
onCancel() {
|
||||
drawerApi.close();
|
||||
},
|
||||
onConfirm() {
|
||||
if (selectedResult.value?.url) {
|
||||
window.open(selectedResult.value.url, '_blank');
|
||||
}
|
||||
drawerApi.close();
|
||||
},
|
||||
});
|
||||
|
||||
/** 切换展开/收起 */
|
||||
function toggleExpanded() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
}
|
||||
|
||||
/** 点击搜索结果 */
|
||||
function handleClick(result: AiChatMessageApi.WebSearchPage) {
|
||||
selectedResult.value = result;
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
/** 图标加载失败处理 */
|
||||
function handleIconError(index: number) {
|
||||
iconLoadError.value[index] = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="webSearchPages && webSearchPages.length > 0" class="mt-2.5">
|
||||
<!-- 标题栏:可点击展开/收起 -->
|
||||
<div
|
||||
class="mb-2 flex cursor-pointer items-center justify-between text-sm text-gray-600 transition-colors hover:text-blue-500"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<IconifyIcon icon="lucide:search" :size="14" />
|
||||
<span>联网搜索结果 ({{ webSearchPages.length }} 条)</span>
|
||||
</div>
|
||||
<IconifyIcon
|
||||
:icon="isExpanded ? 'lucide:chevron-up' : 'lucide:chevron-down'"
|
||||
class="text-xs transition-transform duration-200"
|
||||
:size="12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 可展开的搜索结果列表 -->
|
||||
<div
|
||||
v-show="isExpanded"
|
||||
class="flex flex-col gap-2 transition-all duration-200 ease-in-out"
|
||||
>
|
||||
<div
|
||||
v-for="(page, index) in webSearchPages"
|
||||
:key="index"
|
||||
class="cursor-pointer rounded-md bg-white p-2.5 transition-all hover:bg-blue-50"
|
||||
@click="handleClick(page)"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<!-- 网站图标 -->
|
||||
<div class="mt-0.5 h-4 w-4 flex-shrink-0">
|
||||
<img
|
||||
v-if="page.icon && !iconLoadError[index]"
|
||||
:src="page.icon"
|
||||
:alt="page.name"
|
||||
class="h-full w-full rounded-sm object-contain"
|
||||
@error="handleIconError(index)"
|
||||
/>
|
||||
<IconifyIcon
|
||||
v-else
|
||||
icon="lucide:link"
|
||||
class="h-full w-full text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<!-- 内容区域 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- 网站名称 -->
|
||||
<div class="mb-1 truncate text-xs text-gray-400">
|
||||
{{ page.name }}
|
||||
</div>
|
||||
<!-- 主标题 -->
|
||||
<div
|
||||
class="mb-1 line-clamp-2 text-sm font-medium leading-snug text-blue-600"
|
||||
>
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<!-- 描述 -->
|
||||
<div class="mb-1 line-clamp-2 text-xs leading-snug text-gray-600">
|
||||
{{ page.snippet }}
|
||||
</div>
|
||||
<!-- URL -->
|
||||
<div class="truncate text-xs text-green-700">
|
||||
{{ page.url }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联网搜索详情 Drawer -->
|
||||
<Drawer class="w-[600px]" cancel-text="关闭" confirm-text="访问原文">
|
||||
<div v-if="selectedResult">
|
||||
<!-- 标题区域 -->
|
||||
<div class="mb-4 flex items-start gap-3">
|
||||
<div class="mt-0.5 h-6 w-6 flex-shrink-0">
|
||||
<img
|
||||
v-if="selectedResult.icon"
|
||||
:src="selectedResult.icon"
|
||||
:alt="selectedResult.name"
|
||||
class="h-full w-full rounded-sm object-contain"
|
||||
/>
|
||||
<IconifyIcon
|
||||
v-else
|
||||
icon="lucide:link"
|
||||
class="h-full w-full text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-2 text-lg font-bold text-gray-900">
|
||||
{{ selectedResult.title }}
|
||||
</div>
|
||||
<div class="mb-1 text-sm text-gray-500">
|
||||
{{ selectedResult.name }}
|
||||
</div>
|
||||
<div class="break-all text-sm text-green-700">
|
||||
{{ selectedResult.url }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 内容区域 -->
|
||||
<div class="space-y-4">
|
||||
<!-- 简短描述 -->
|
||||
<div>
|
||||
<div class="mb-2 text-sm font-semibold text-gray-900">简短描述</div>
|
||||
<div
|
||||
class="rounded-lg bg-gray-50 p-3 text-sm leading-relaxed text-gray-700"
|
||||
>
|
||||
{{ selectedResult.snippet }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 内容摘要 -->
|
||||
<div v-if="selectedResult.summary">
|
||||
<div class="mb-2 text-sm font-semibold text-gray-900">内容摘要</div>
|
||||
<div
|
||||
class="max-h-[50vh] overflow-y-auto whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-sm leading-relaxed text-gray-900"
|
||||
>
|
||||
{{ selectedResult.summary }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import { ElButton } from 'element-plus';
|
||||
|
||||
defineProps({
|
||||
categoryList: {
|
||||
type: Array as PropType<string[]>,
|
||||
required: true,
|
||||
},
|
||||
active: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '全部',
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['onCategoryClick']); // 定义回调
|
||||
|
||||
/** 处理分类点击事件 */
|
||||
async function handleCategoryClick(category: string) {
|
||||
emits('onCategoryClick', category);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div
|
||||
class="mr-2 flex flex-row"
|
||||
v-for="category in categoryList"
|
||||
:key="category"
|
||||
>
|
||||
<ElButton
|
||||
size="small"
|
||||
round
|
||||
:type="category === active ? 'primary' : 'default'"
|
||||
@click="handleCategoryClick(category)"
|
||||
>
|
||||
{{ category }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
123
apps/web-ele/src/views/ai/chat/index/modules/role/list.vue
Normal file
123
apps/web-ele/src/views/ai/chat/index/modules/role/list.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { AiModelChatRoleApi } from '#/api/ai/model/chatRole';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
} from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
roleList: {
|
||||
type: Array as PropType<AiModelChatRoleApi.ChatRole[]>,
|
||||
required: true,
|
||||
},
|
||||
showMore: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage']);
|
||||
|
||||
const tabsRef = ref<any>();
|
||||
|
||||
/** 操作:编辑、删除 */
|
||||
async function handleMoreClick(type: string, role: any) {
|
||||
if (type === 'delete') {
|
||||
emits('onDelete', role);
|
||||
} else {
|
||||
emits('onEdit', role);
|
||||
}
|
||||
}
|
||||
|
||||
/** 选中 */
|
||||
function handleUseClick(role: any) {
|
||||
emits('onUse', role);
|
||||
}
|
||||
|
||||
/** 滚动 */
|
||||
async function handleTabsScroll() {
|
||||
if (tabsRef.value) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = tabsRef.value;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) {
|
||||
emits('onPage');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex h-full flex-wrap content-start items-start overflow-auto px-6 pb-36"
|
||||
ref="tabsRef"
|
||||
@scroll="handleTabsScroll"
|
||||
>
|
||||
<div class="mb-5 mr-5 inline-block" v-for="role in roleList" :key="role.id">
|
||||
<ElCard
|
||||
class="relative rounded-lg"
|
||||
body-style="position: relative; display: flex; flex-direction: row; justify-content: flex-start; width: 240px; max-width: 240px; padding: 15px 15px 10px;"
|
||||
>
|
||||
<!-- 更多操作 -->
|
||||
<div v-if="showMore" class="absolute right-2 top-0">
|
||||
<ElDropdown>
|
||||
<ElButton link>
|
||||
<IconifyIcon icon="lucide:ellipsis-vertical" />
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem @click="handleMoreClick('edit', role)">
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon icon="lucide:edit" color="#787878" />
|
||||
<span class="text-primary">编辑</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem @click="handleMoreClick('delete', role)">
|
||||
<div class="flex items-center">
|
||||
<IconifyIcon icon="lucide:trash" color="red" />
|
||||
<span class="text-red-500">删除</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
|
||||
<!-- 角色信息 -->
|
||||
<div>
|
||||
<ElAvatar :src="role.avatar" class="h-10 w-10 overflow-hidden" />
|
||||
</div>
|
||||
|
||||
<div class="ml-2 w-4/5">
|
||||
<div class="h-20">
|
||||
<div class="max-w-32 text-lg font-bold">
|
||||
{{ role.name }}
|
||||
</div>
|
||||
<div class="mt-2 text-sm">
|
||||
{{ role.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-row-reverse">
|
||||
<ElButton type="primary" size="small" @click="handleUseClick(role)">
|
||||
使用
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
261
apps/web-ele/src/views/ai/chat/index/modules/role/repository.vue
Normal file
261
apps/web-ele/src/views/ai/chat/index/modules/role/repository.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
|
||||
import type { AiModelChatRoleApi } from '#/api/ai/model/chatRole';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElContainer,
|
||||
ElInput,
|
||||
ElMain,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
} from 'element-plus';
|
||||
|
||||
import { createChatConversationMy } from '#/api/ai/chat/conversation';
|
||||
import { deleteMy, getCategoryList, getMyPage } from '#/api/ai/model/chatRole';
|
||||
|
||||
import Form from '../../../../model/chatRole/modules/form.vue';
|
||||
import RoleCategoryList from './category-list.vue';
|
||||
import RoleList from './list.vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [Drawer] = useVbenDrawer({
|
||||
title: '角色管理',
|
||||
footer: false,
|
||||
class: 'w-2/5',
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const loading = ref<boolean>(false); // 加载中
|
||||
const activeTab = ref<string>('my-role'); // 选中的角色 Tab
|
||||
const search = ref<string>(''); // 加载中
|
||||
const myRoleParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
const myRoleList = ref<AiModelChatRoleApi.ChatRole[]>([]); // my 分页大小
|
||||
const publicRoleParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
const publicRoleList = ref<AiModelChatRoleApi.ChatRole[]>([]); // public 分页大小
|
||||
const activeCategory = ref<string>('全部'); // 选择中的分类
|
||||
const categoryList = ref<string[]>([]); // 角色分类类别
|
||||
|
||||
/** tabs 点击 */
|
||||
async function handleTabsClick(tab: any) {
|
||||
// 设置切换状态
|
||||
activeTab.value = tab;
|
||||
// 切换的时候重新加载数据
|
||||
await getActiveTabsRole();
|
||||
}
|
||||
|
||||
/** 获取 my role 我的角色 */
|
||||
async function getMyRole(append?: boolean) {
|
||||
const params: AiModelChatRoleApi.ChatRolePageReqVO = {
|
||||
...myRoleParams,
|
||||
name: search.value,
|
||||
publicStatus: false,
|
||||
};
|
||||
const { list } = await getMyPage(params);
|
||||
if (append) {
|
||||
myRoleList.value.push(...list);
|
||||
} else {
|
||||
myRoleList.value = list;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取 public role 公共角色 */
|
||||
async function getPublicRole(append?: boolean) {
|
||||
const params: AiModelChatRoleApi.ChatRolePageReqVO = {
|
||||
...publicRoleParams,
|
||||
category: activeCategory.value === '全部' ? '' : activeCategory.value,
|
||||
name: search.value,
|
||||
publicStatus: true,
|
||||
};
|
||||
const { list } = await getMyPage(params);
|
||||
if (append) {
|
||||
publicRoleList.value.push(...list);
|
||||
} else {
|
||||
publicRoleList.value = list;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取选中的 tabs 角色 */
|
||||
async function getActiveTabsRole() {
|
||||
if (activeTab.value === 'my-role') {
|
||||
myRoleParams.pageNo = 1;
|
||||
await getMyRole();
|
||||
} else {
|
||||
publicRoleParams.pageNo = 1;
|
||||
await getPublicRole();
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取角色分类列表 */
|
||||
async function getRoleCategoryList() {
|
||||
categoryList.value = ['全部', ...(await getCategoryList())];
|
||||
}
|
||||
|
||||
/** 处理分类点击 */
|
||||
async function handlerCategoryClick(category: string) {
|
||||
// 切换选择的分类
|
||||
activeCategory.value = category;
|
||||
// 筛选
|
||||
await getActiveTabsRole();
|
||||
}
|
||||
|
||||
async function handlerAddRole() {
|
||||
formModalApi.setData({ formType: 'my-create' }).open();
|
||||
}
|
||||
/** 编辑角色 */
|
||||
async function handlerCardEdit(role: any) {
|
||||
formModalApi.setData({ formType: 'my-update', id: role.id }).open();
|
||||
}
|
||||
|
||||
/** 添加角色成功 */
|
||||
async function handlerAddRoleSuccess() {
|
||||
// 刷新数据
|
||||
await getActiveTabsRole();
|
||||
}
|
||||
|
||||
/** 删除角色 */
|
||||
async function handlerCardDelete(role: any) {
|
||||
await deleteMy(role.id);
|
||||
// 刷新数据
|
||||
await getActiveTabsRole();
|
||||
}
|
||||
|
||||
/** 角色分页:获取下一页 */
|
||||
async function handlerCardPage(type: string) {
|
||||
try {
|
||||
loading.value = true;
|
||||
if (type === 'public') {
|
||||
publicRoleParams.pageNo++;
|
||||
await getPublicRole(true);
|
||||
} else {
|
||||
myRoleParams.pageNo++;
|
||||
await getMyRole(true);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择 card 角色:新建聊天对话 */
|
||||
async function handlerCardUse(role: any) {
|
||||
// 1. 创建对话
|
||||
const data: AiChatConversationApi.ChatConversation = {
|
||||
roleId: role.id,
|
||||
} as unknown as AiChatConversationApi.ChatConversation;
|
||||
const conversationId = await createChatConversationMy(data);
|
||||
|
||||
// 2. 跳转页面
|
||||
await router.push({
|
||||
path: '/ai/chat',
|
||||
query: {
|
||||
conversationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
// 获取分类
|
||||
await getRoleCategoryList();
|
||||
// 获取 role 数据
|
||||
await getActiveTabsRole();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<ElContainer
|
||||
class="bg-card absolute inset-0 flex h-full w-full flex-col overflow-hidden"
|
||||
>
|
||||
<FormModal @success="handlerAddRoleSuccess" />
|
||||
|
||||
<ElMain class="relative m-0 flex-1 overflow-hidden p-0">
|
||||
<div class="z-100 absolute right-0 top--1 mr-5 mt-5">
|
||||
<!-- 搜索输入框 -->
|
||||
<ElInput
|
||||
v-model="search"
|
||||
class="w-60"
|
||||
placeholder="请输入搜索的内容"
|
||||
@keyup.enter="getActiveTabsRole"
|
||||
>
|
||||
<template #suffix>
|
||||
<IconifyIcon icon="lucide:search" />
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElButton
|
||||
v-if="activeTab === 'my-role'"
|
||||
type="primary"
|
||||
@click="handlerAddRole"
|
||||
class="ml-5"
|
||||
>
|
||||
<IconifyIcon icon="lucide:user" class="mr-1.5" />
|
||||
添加角色
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<!-- 标签页内容 -->
|
||||
<ElTabs
|
||||
v-model="activeTab"
|
||||
class="relative h-full p-4"
|
||||
@tab-click="handleTabsClick"
|
||||
>
|
||||
<ElTabPane
|
||||
name="my-role"
|
||||
class="flex h-full flex-col overflow-y-auto"
|
||||
label="我的角色"
|
||||
>
|
||||
<RoleList
|
||||
:loading="loading"
|
||||
:role-list="myRoleList"
|
||||
:show-more="true"
|
||||
@on-delete="handlerCardDelete"
|
||||
@on-edit="handlerCardEdit"
|
||||
@on-use="handlerCardUse"
|
||||
@on-page="handlerCardPage('my')"
|
||||
class="mt-5"
|
||||
/>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane
|
||||
name="public-role"
|
||||
class="flex h-full flex-col overflow-y-auto"
|
||||
label="公共角色"
|
||||
>
|
||||
<RoleCategoryList
|
||||
:category-list="categoryList"
|
||||
:active="activeCategory"
|
||||
@on-category-click="handlerCategoryClick"
|
||||
class="mx-6"
|
||||
/>
|
||||
<RoleList
|
||||
:role-list="publicRoleList"
|
||||
@on-delete="handlerCardDelete"
|
||||
@on-edit="handlerCardEdit"
|
||||
@on-use="handlerCardUse"
|
||||
@on-page="handlerCardPage('public')"
|
||||
class="mt-5"
|
||||
loading
|
||||
/>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</ElMain>
|
||||
</ElContainer>
|
||||
</Drawer>
|
||||
</template>
|
||||
Reference in New Issue
Block a user