feat(ai): 添加 AI 聊天功能

- 新增 AI 聊天对话和消息相关 API
- 实现聊天界面,包括对话列表、消息列表、发送消息等功能
- 添加音乐生成功能的初始框架
pull/145/head
gjd 2025-06-12 18:26:10 +08:00
parent d2fbb5a18b
commit 4596cd9fa5
25 changed files with 3109 additions and 94 deletions

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1715352878351" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1499" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M624.5 786.3c92.9 0 168.2-75.3 168.2-168.2V309c0-92.4-75.3-168.2-168.2-168.2H303.6c-92.4 0-168.2 75.3-168.2 168.2v309.1c0 92.4 75.3 168.2 168.2 168.2h320.9zM178.2 618.1V309c0-69.4 56.1-125.5 125.5-125.5h320.9c69.4 0 125.5 56.1 125.5 125.5v309.1c0 69.4-56.1 125.5-125.5 125.5h-321c-69.4 0-125.4-56.1-125.4-125.5z" p-id="1500" fill="#8a8a8a"></path><path d="M849.8 295.1v361.5c0 102.7-83.6 186.3-186.3 186.3H279.1v42.7h384.4c126.3 0 229.1-102.8 229.1-229.1V295.1h-42.8zM307.9 361.8h312.3c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.9 9.6 21.4 21.4 21.4zM307.9 484.6h312.3c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.9 9.6 21.4 21.4 21.4z" p-id="1501" fill="#8a8a8a"></path><path d="M620.2 607.4c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.8 9.6 21.4 21.4 21.4h312.3z" p-id="1502" fill="#8a8a8a"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1715354120346" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3256" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M907.1 263.7H118.9c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4H907c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3257"></path><path d="M772.5 928.3H257.4c-27.7 0-50.2-22.5-50.2-50.2V247.2c0-9.1 7.3-16.4 16.4-16.4H801c12.1 0 21.9 9.8 21.9 21.9v625.2c0 27.8-22.6 50.4-50.4 50.4zM240 263.7v614.4c0 9.6 7.8 17.4 17.4 17.4h515.2c9.7 0 17.5-7.9 17.5-17.5V263.7H240zM657.4 131.1H368.6c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4h288.7c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3258"></path><path d="M416 754.5c-9.1 0-16.4-7.3-16.4-16.4V517.8c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0.1 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" p-id="3259"></path><path d="M416 465.2c-9.1 0-16.4-7.3-16.4-16.4v-59.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v59.4c0.1 9.1-7.3 16.4-16.4 16.4zM604.9 754.5c-9.1 0-16.4-7.3-16.4-16.4v-67.2c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" opacity=".4" p-id="3260"></path><path d="M604.9 619.1c-9.1 0-16.4-7.3-16.4-16.4V389.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v213.3c0 9.1-7.3 16.4-16.4 16.4z" fill="#8a8a8a" p-id="3261"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1716345268026" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5622" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M956.408445 419.226665a250.670939 250.670939 0 0 0-22.425219-209.609236A263.163526 263.163526 0 0 0 652.490412 85.715535 259.784384 259.784384 0 0 0 457.728923 0.008192a261.422756 261.422756 0 0 0-249.44216 178.582564 258.453206 258.453206 0 0 0-172.848261 123.901894c-57.03583 96.868753-44.031251 219.132275 32.153053 302.279661a250.670939 250.670939 0 0 0 22.32282 209.609237 263.163526 263.163526 0 0 0 281.595213 123.901893A259.067596 259.067596 0 0 0 566.271077 1023.990784a260.60357 260.60357 0 0 0 249.339762-178.889759 258.453206 258.453206 0 0 0 172.848261-123.901893c57.445423-96.868753 44.13365-218.82508-32.050655-302.074865zM566.578272 957.124721c-45.362429 0-89.496079-15.666934-124.516283-44.543243 1.638372-0.921584 4.198329-2.150363 6.143895-3.481541l206.537289-117.757998a32.35785 32.35785 0 0 0 16.895713-29.081105V474.82892l87.243317 49.97035c1.023983 0.307195 1.638372 1.228779 1.638372 2.252762v238.075953c0 105.8798-86.936122 191.689541-193.942303 191.996736zM148.588578 781.102113a189.846373 189.846373 0 0 1-23.346803-128.612213c1.535974 1.023983 4.09593 2.559956 6.143895 3.48154L337.922959 773.729439c10.444622 6.143896 23.346803 6.143896 34.098621 0l252.30931-143.664758v99.531108c0 1.023983-0.307195 1.945567-1.331177 2.559956l-208.892449 118.986778a196.297463 196.297463 0 0 1-265.518686-70.04041zM94.112704 335.97688c22.630015-39.013737 58.367008-68.81163 101.16948-84.171369V494.591784c0 11.7758 6.45109 22.93721 16.793315 28.978707l252.30931 143.767156L377.141493 716.796006a3.174346 3.174346 0 0 1-2.867152 0.307195l-208.892448-118.986777A190.870355 190.870355 0 0 1 94.215102 335.874482z m717.607001 164.861198L559.410394 357.070922 646.653711 307.20297a3.174346 3.174346 0 0 1 2.969549-0.307195l208.892449 118.986777a190.358364 190.358364 0 0 1 70.961994 262.139544 194.556693 194.556693 0 0 1-101.16948 84.171369V529.407192a31.538664 31.538664 0 0 0-16.588518-28.671513z m87.03852-129.329002c-1.74077-1.023983-4.300727-2.559956-6.246294-3.48154l-206.639687-117.757999a34.09862 34.09862 0 0 0-33.996222 0L399.566711 393.934295v-99.531108c0-1.023983 0.307195-1.945567 1.331178-2.559956l208.892449-119.089176a195.990268 195.990268 0 0 1 265.518686 70.450003c22.732414 38.706542 31.129071 84.171369 23.346803 128.305018zM352.258716 548.862861l-87.243317-49.560757a2.457558 2.457558 0 0 1-1.638372-2.252762V258.870991c0-105.8798 87.243317-191.996736 194.556692-191.689541a194.556693 194.556693 0 0 1 124.209089 44.543243c-1.638372 0.921584-4.198329 2.252762-6.143896 3.48154l-206.639687 117.757999a31.948257 31.948257 0 0 0-16.793315 29.081105l-0.307194 286.715126z m47.307995-100.759887L512 384.001664l112.535687 63.998912v127.997824l-112.228492 63.998912-112.535687-63.998912-0.307195-127.997824z" p-id="5623" fill="#707070"></path></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -26,9 +26,9 @@ export namespace AiChatConversationApi {
// 获得【我的】聊天对话
export function getChatConversationMy(id: number) {
return requestClient.get<
PageResult<AiChatConversationApi.ChatConversationVO>
>(`/ai/chat/conversation/get-my?id=${id}`);
return requestClient.get<AiChatConversationApi.ChatConversationVO>(
`/ai/chat/conversation/get-my?id=${id}`,
);
}
// 新增【我的】聊天对话
@ -46,7 +46,7 @@ export function updateChatConversationMy(
}
// 删除【我的】聊天对话
export function deleteChatConversationMy(id: string) {
export function deleteChatConversationMy(id: number) {
return requestClient.delete(`/ai/chat/conversation/delete-my?id=${id}`);
}

View File

@ -1,10 +1,12 @@
import type { PageResult } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { fetchEventSource } from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { requestClient } from '#/api/request';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
export namespace AiChatMessageApi {
export interface ChatMessageVO {
@ -50,30 +52,27 @@ export function sendChatMessageStream(
onClose: any,
) {
const token = accessStore.accessToken;
return fetchEventSource(
`${import.meta.env.VITE_BASE_URL}/ai/chat/message/send-stream`,
{
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
openWhenHidden: true,
body: JSON.stringify({
conversationId,
content,
useContext: enableContext,
}),
onmessage: onMessage,
onerror: onError,
onclose: onClose,
signal: ctrl.signal,
return fetchEventSource(`${apiURL}/ai/chat/message/send-stream`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
);
openWhenHidden: true,
body: JSON.stringify({
conversationId,
content,
useContext: enableContext,
}),
onmessage: onMessage,
onerror: onError,
onclose: onClose,
signal: ctrl.signal,
});
}
// 删除消息
export function deleteChatMessage(id: string) {
export function deleteChatMessage(id: number) {
return requestClient.delete(`/ai/chat/message/delete?id=${id}`);
}

View File

@ -1,8 +1,10 @@
import { useAppConfig } from '@vben/hooks';
import { fetchEventSource } from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { requestClient } from '#/api/request';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
export namespace AiMindmapApi {
// AI 思维导图 VO
@ -36,22 +38,19 @@ export function generateMindMap({
onMessage?: (res: any) => void;
}) {
const token = accessStore.accessToken;
return fetchEventSource(
`${import.meta.env.VITE_BASE_URL}/ai/mind-map/generate-stream`,
{
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
openWhenHidden: true,
body: JSON.stringify(data),
onmessage: onMessage,
onerror: onError,
onclose: onClose,
signal: ctrl.signal,
return fetchEventSource(`${apiURL}/ai/mind-map/generate-stream`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
);
openWhenHidden: true,
body: JSON.stringify(data),
onmessage: onMessage,
onerror: onError,
onclose: onClose,
signal: ctrl.signal,
});
}
// 查询思维导图分页

View File

@ -2,11 +2,13 @@ import type { PageParam, PageResult } from '@vben/request';
import type { AiWriteTypeEnum } from '#/utils/constants';
import { useAppConfig } from '@vben/hooks';
import { fetchEventSource } from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { requestClient } from '#/api/request';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
export namespace AiWriteApi {
export interface WriteVO {
@ -64,22 +66,19 @@ export function writeStream({
onMessage?: (res: any) => void;
}) {
const token = accessStore.accessToken;
return fetchEventSource(
`${import.meta.env.VITE_BASE_URL}/ai/write/generate-stream`,
{
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
openWhenHidden: true,
body: JSON.stringify(data),
onmessage: onMessage,
onerror: onError,
onclose: onClose,
signal: ctrl.signal,
return fetchEventSource(`${apiURL}/ai/write/generate-stream`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
);
openWhenHidden: true,
body: JSON.stringify(data),
onmessage: onMessage,
onerror: onError,
onclose: onClose,
signal: ctrl.signal,
});
}
// 获取写作列表

View File

@ -0,0 +1,209 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { MarkdownIt } from '@vben/plugins/markmap';
import { useClipboard } from '@vueuse/core';
import { message } from 'ant-design-vue';
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.min.css';
//
const props = defineProps({
content: {
type: String,
required: true,
},
});
const { copy } = useClipboard(); // copy
const contentRef = ref();
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(lang, str, 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);
message.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>

View File

@ -0,0 +1,546 @@
<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 } from '@vben/icons';
import { Button, Empty, Input, Layout, message } from 'ant-design-vue';
import {
createChatConversationMy,
deleteChatConversationMy,
deleteChatConversationMyByUnpinned,
getChatConversationMyList,
updateChatConversationMy,
} from '#/api/ai/chat/conversation';
import RoleRepository from '../role/RoleRepository.vue';
//
// props
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.ChatConversationVO[]); //
const conversationMap = ref<any>({}); // ()
const loading = ref<boolean>(false); //
const loadingTime = ref<any>();
/** 搜索对话 */
const searchConversation = async () => {
//
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);
}
};
/** 点击对话 */
const handleConversationClick = async (id: number) => {
//
const filterConversation = conversationList.value.find((item) => {
return item.id === id;
});
// onConversationClick
// noinspection JSVoidFunctionReturnValueUsed
const success = emits('onConversationClick', filterConversation);
//
if (success) {
activeConversationId.value = id;
}
};
/** 获取对话列表 */
const getChatConversationList = async () => {
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 创建时间,进行分组 */
const getConversationGroupByCreateTime = async (
list: AiChatConversationApi.ChatConversationVO[],
) => {
// (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;
};
const createConversation = async () => {
// 1.
const conversationId = await createChatConversationMy(
{} as unknown as AiChatConversationApi.ChatConversationVO,
);
// 2.
await getChatConversationList();
// 3.
await handleConversationClick(conversationId);
// 4.
emits('onConversationCreate');
};
/** 修改对话的标题 */
const updateConversationTitle = async (
conversation: AiChatConversationApi.ChatConversationVO,
) => {
// 1.
prompt({
async beforeClose(scope) {
if (scope.isConfirm) {
if (scope.value) {
try {
// 2.
await updateChatConversationMy({
id: conversation.id,
title: scope.value,
} as AiChatConversationApi.ChatConversationVO);
message.success('重命名成功');
// 3.
await getChatConversationList();
// 4.
const filterConversationList = conversationList.value.filter(
(item) => {
return item.id === conversation.id;
},
);
if (
filterConversationList.length > 0 &&
filterConversationList[0] && // tip
activeConversationId.value === filterConversationList[0].id
) {
emits('onConversationClick', filterConversationList[0]);
}
} catch {
return false;
}
} else {
message.error('请输入标题');
return false;
}
}
},
component: () => {
return h(Input, {
placeholder: '请输入标题',
allowClear: true,
defaultValue: conversation.title,
rules: [{ required: true, message: '请输入标题' }],
});
},
content: '请输入标题',
title: '修改标题',
modelPropName: 'value',
});
};
/** 删除聊天对话 */
const deleteChatConversation = async (
conversation: AiChatConversationApi.ChatConversationVO,
) => {
try {
//
await confirm(`是否确认删除对话 - ${conversation.title}?`);
//
await deleteChatConversationMy(conversation.id);
message.success('对话已删除');
//
await getChatConversationList();
//
emits('onConversationDelete', conversation);
} catch {}
};
const handleClearConversation = async () => {
try {
await confirm('确认后对话会全部清空,置顶的对话除外。');
await deleteChatConversationMyByUnpinned();
message.success('操作成功!');
//
activeConversationId.value = null;
//
await getChatConversationList();
//
emits('onConversationClear');
} catch {}
};
/** 对话置顶 */
const handleTop = async (
conversation: AiChatConversationApi.ChatConversationVO,
) => {
//
conversation.pinned = !conversation.pinned;
await updateChatConversationMy(conversation);
//
await getChatConversationList();
};
// ============ ============
/** 角色仓库抽屉 */
const handleRoleRepository = async () => {
drawerApi.open();
};
/** 监听选中的对话 */
const { activeId } = toRefs(props);
watch(activeId, async (newValue) => {
activeConversationId.value = newValue;
});
// public
defineExpose({ createConversation });
/** 初始化 */
onMounted(async () => {
//
await getChatConversationList();
//
if (props.activeId) {
activeConversationId.value = props.activeId;
} else {
//
if (conversationList.value.length > 0 && conversationList.value[0]) {
activeConversationId.value = conversationList.value[0].id;
// onConversationClick
await emits('onConversationClick', conversationList.value[0]);
}
}
});
</script>
<template>
<Layout.Sider width="260px" class="conversation-container h-full">
<Drawer />
<!-- 左顶部对话 -->
<div class="flex h-full" style="flex-direction: column">
<Button
class="w-1/1 btn-new-conversation"
type="primary"
@click="createConversation"
>
<IconifyIcon icon="ep:plus" class="mr-[5px]" />
新建对话
</Button>
<!-- 左顶部搜索对话 -->
<Input
v-model:value="searchName"
size="large"
class="search-input mt-[10px]"
placeholder="搜索历史记录"
@keyup="searchConversation"
>
<template #prefix>
<IconifyIcon icon="ep:search" />
</template>
</Input>
<!-- 左中间对话列表 -->
<div class="conversation-list">
<!-- 情况一加载中 -->
<Empty v-if="loading" description="." v-loading="loading" />
<!-- 情况二按照 group 分组展示聊天会话 list 列表 -->
<div
v-for="conversationKey in Object.keys(conversationMap)"
:key="conversationKey"
>
<div
class="conversation-item classify-title"
v-if="conversationMap[conversationKey].length > 0"
>
<b class="mx-1">
{{ conversationKey }}
</b>
</div>
<div
class="conversation-item"
v-for="conversation in conversationMap[conversationKey]"
:key="conversation.id"
@click="handleConversationClick(conversation.id)"
@mouseover="hoverConversationId = conversation.id"
@mouseout="hoverConversationId = null"
>
<div
:class="
conversation.id === activeConversationId
? 'conversation active'
: 'conversation'
"
>
<div class="title-wrapper">
<img
class="avatar"
:src="conversation.roleAvatar ?? '/static/gpt.svg'"
/>
<span class="title">{{ conversation.title }}</span>
</div>
<div
class="button-wrapper"
v-show="hoverConversationId === conversation.id"
>
<Button
class="btn"
type="link"
@click.stop="handleTop(conversation)"
>
<span
v-if="!conversation.pinned"
class="icon-[ant-design--arrow-up-outlined]"
></span>
<span
v-if="conversation.pinned"
class="icon-[ant-design--arrow-down-outlined]"
></span>
</Button>
<Button
class="btn"
type="link"
@click.stop="updateConversationTitle(conversation)"
>
<IconifyIcon icon="ep:edit" />
</Button>
<Button
class="btn"
type="link"
@click.stop="deleteChatConversation(conversation)"
>
<IconifyIcon icon="ep:delete" />
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- 底部占位 -->
<div class="w-100% h-[50px]"></div>
</div>
<!-- 左底部工具栏 -->
<div class="tool-box">
<div @click="handleRoleRepository">
<IconifyIcon icon="ep:user" />
<span>角色仓库</span>
</div>
<div @click="handleClearConversation">
<IconifyIcon icon="ep:delete" />
<span>清空未置顶对话</span>
</div>
</div>
</Layout.Sider>
</template>
<style scoped lang="scss">
.conversation-container {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 10px 10px 0;
overflow: hidden;
background-color: hsl(var(--primary-foreground));
.btn-new-conversation {
width: 100%;
height: 38px;
}
.search-input {
margin-top: 20px;
}
.conversation-list {
height: 100%;
overflow: auto;
.classify-title {
padding-top: 10px;
}
.conversation-item {
margin-top: 5px;
}
.conversation {
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 5px;
line-height: 30px;
cursor: pointer;
border-radius: 5px;
&.active {
background-color: #e6e6e6;
.button {
display: inline-block;
}
}
.title-wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.title {
max-width: 150px;
padding: 2px 10px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 400;
color: rgb(0 0 0 / 77%);
white-space: nowrap;
}
.avatar {
display: flex;
flex-direction: row;
justify-items: center;
width: 25px;
height: 25px;
border-radius: 5px;
}
//
.button-wrapper {
right: 2px;
display: flex;
flex-direction: row;
place-items: center center;
color: #606266;
.btn {
padding: 0 5px 0 0;
margin: 0;
}
}
}
}
//
.tool-box {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: space-between;
//width: 100%;
padding: 0 20px;
line-height: 35px;
color: var(--el-text-color);
background-color: #f4f4f4;
box-shadow: 0 0 1px 1px rgb(228 228 228 / 80%);
> div {
display: flex;
align-items: center;
padding: 0;
margin: 0;
color: #606266;
cursor: pointer;
> span {
margin-left: 5px;
}
}
}
}
</style>

View File

@ -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 { message } from 'ant-design-vue';
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.ChatConversationVO>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 140,
},
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.ChatConversationVO;
try {
await updateChatConversationMy(data);
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<AiChatConversationApi.ChatConversationVO>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getChatConversationMy(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[600px]" title="设定">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Tooltip } from 'ant-design-vue';
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 处理 */
const handleClick = (doc: any) => {
document.value = doc;
dialogVisible.value = true;
};
</script>
<template>
<!-- 知识引用列表 -->
<div
v-if="segments && segments.length > 0"
class="mt-[10px] rounded-[8px] bg-[#f5f5f5] p-[10px]"
>
<div class="text-14px mb-8px flex items-center text-[#666]">
<IconifyIcon icon="ep:document" class="mr-[5px]" /> 知识引用
</div>
<div class="flex flex-wrap gap-[8px]">
<div
v-for="(doc, index) in documentList"
:key="index"
class="cursor-pointer rounded-[6px] bg-white p-[8px] px-[12px] transition-all hover:bg-[#e6f4ff]"
@click="handleClick(doc)"
>
<div class="mb-[4px] text-[14px] text-[#333]">
{{ doc.title }}
<span class="ml-[4px] text-[12px] text-[#999]">
{{ doc.segments.length }}
</span>
</div>
</div>
</div>
</div>
<Tooltip placement="topLeft" trigger="click">
<div ref="documentRef"></div>
<template #title>
<div class="mb-[12px] text-[16px] 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-[#eee] p-[12px] last:border-b-0"
>
<div
class="mb-[8px] block w-fit rounded-[4px] bg-[#f5f5f5] px-[8px] py-[2px] text-[12px] text-[#666]"
>
分段 {{ segment.id }}
</div>
<div class="mt-[10px] text-[14px] leading-[1.6] text-[#333]">
{{ segment.content }}
</div>
</div>
</div>
</template>
</Tooltip>
</template>

View File

@ -0,0 +1,296 @@
<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 { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import { formatDate } from '@vben/utils';
import { useClipboard } from '@vueuse/core';
import { Avatar, Button, message } from 'ant-design-vue';
import { deleteChatMessage } from '#/api/ai/chat/message';
import MarkdownView from '#/components/MarkdownView/index.vue';
import MessageKnowledge from './MessageKnowledge.vue';
// props
const props = defineProps({
conversation: {
type: Object as PropType<AiChatConversationApi.ChatConversationVO>,
required: true,
},
list: {
type: Array as PropType<AiChatMessageApi.ChatMessageVO[]>,
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 roleAvatar = computed(
() => props.conversation.roleAvatar ?? '/static/gpt.svg',
);
const { list } = toRefs(props); // emits
// ============ ==============
/** 滚动到底部 */
const scrollToBottom = async (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;
}
/** 回到底部 */
const handleGoBottom = async () => {
const scrollContainer = messageContainer.value;
scrollContainer.scrollTop = scrollContainer.scrollHeight;
};
/** 回到顶部 */
const handlerGoTop = async () => {
const scrollContainer = messageContainer.value;
scrollContainer.scrollTop = 0;
};
defineExpose({ scrollToBottom, handlerGoTop }); // parent
// ============ ==============
/** 复制 */
const copyContent = async (content: string) => {
await copy(content);
message.success('复制成功!');
};
/** 删除 */
const onDelete = async (id: number) => {
// message
await deleteChatMessage(id);
message.success('删除成功!');
//
emits('onDeleteSuccess');
};
/** 刷新 */
const onRefresh = async (message: AiChatMessageApi.ChatMessageVO) => {
emits('onRefresh', message);
};
/** 编辑 */
const onEdit = async (message: AiChatMessageApi.ChatMessageVO) => {
emits('onEdit', message);
};
/** 初始化 */
onMounted(async () => {
messageContainer.value.addEventListener('scroll', handleScroll);
});
</script>
<template>
<div ref="messageContainer" class="relative h-full overflow-y-auto">
<div class="chat-list" v-for="(item, index) in list" :key="index">
<!-- 靠左 messagesystemassistant 类型 -->
<div class="left-message message-item" v-if="item.type !== 'user'">
<div class="avatar">
<Avatar :src="roleAvatar" />
</div>
<div class="message">
<div>
<div class="time">{{ formatDate(item.createTime) }}</div>
</div>
<div class="left-text-container">
<MarkdownView class="left-text" :content="item.content" />
<MessageKnowledge v-if="item.segments" :segments="item.segments" />
</div>
<div class="left-btns">
<Button
class="btn-cus"
type="text"
@click="copyContent(item.content)"
>
<img class="btn-image" src="/static/copy.svg" />
</Button>
<Button
v-if="item.id > 0"
class="btn-cus"
type="text"
@click="onDelete(item.id)"
>
<img class="btn-image h-[17px]" src="/static/delete.svg" />
</Button>
</div>
</div>
</div>
<!-- 靠右 messageuser 类型 -->
<div class="right-message message-item" v-if="item.type === 'user'">
<div class="avatar">
<Avatar :src="userAvatar" />
</div>
<div class="message">
<div>
<div class="time">{{ formatDate(item.createTime) }}</div>
</div>
<div class="right-text-container">
<div class="right-text">{{ item.content }}</div>
</div>
<div class="right-btns">
<Button
class="btn-cus"
type="text"
@click="copyContent(item.content)"
>
<img class="btn-image" src="/static/copy.svg" />
</Button>
<Button class="btn-cus" type="text" @click="onDelete(item.id)">
<img class="btn-image h-[17px]" src="/static/delete.svg" />
</Button>
<Button class="btn-cus" type="text" @click="onRefresh(item)">
<span class="icon-[ant-design--redo-outlined]"></span>
</Button>
<Button class="btn-cus" type="text" @click="onEdit(item)">
<span class="icon-[ant-design--form-outlined]"></span>
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- 回到底部 -->
<div v-if="isScrolling" class="to-bottom" @click="handleGoBottom">
<Button shape="circle">
<span class="icon-[ant-design--down-outlined]"></span>
</Button>
</div>
</template>
<style scoped lang="scss">
//
.chat-list {
display: flex;
flex-direction: column;
padding: 0 20px;
overflow-y: hidden;
.message-item {
margin-top: 50px;
}
.left-message {
display: flex;
flex-direction: row;
}
.right-message {
display: flex;
flex-direction: row-reverse;
justify-content: flex-start;
}
.message {
display: flex;
flex-direction: column;
margin: 0 15px;
text-align: left;
.time {
line-height: 30px;
text-align: left;
}
.left-text-container {
position: relative;
display: flex;
flex-direction: column;
padding: 10px 10px 5px;
overflow-wrap: break-word;
background-color: rgb(228 228 228 / 80%);
border-radius: 10px;
box-shadow: 0 0 0 1px rgb(228 228 228 / 80%);
.left-text {
font-size: 0.95rem;
color: #393939;
}
}
.right-text-container {
display: flex;
flex-direction: row-reverse;
.right-text {
display: inline;
width: auto;
padding: 10px;
font-size: 0.95rem;
color: #fff;
overflow-wrap: break-word;
white-space: pre-wrap;
background-color: #267fff;
border-radius: 10px;
box-shadow: 0 0 0 1px #267fff;
}
}
.left-btns {
display: flex;
flex-direction: row;
margin-top: 8px;
}
.right-btns {
display: flex;
flex-direction: row-reverse;
margin-top: 8px;
}
}
//
.btn-cus {
display: flex;
align-items: center;
padding: 0 5px;
background-color: transparent;
.btn-image {
height: 20px;
}
}
.btn-cus:hover {
cursor: pointer;
background-color: #f6f6f6;
}
}
//
.to-bottom {
position: absolute;
right: 50%;
bottom: 0;
z-index: 1000;
}
</style>

View File

@ -0,0 +1,81 @@
<!-- 消息列表为空时展示 prompt 列表 -->
<script setup lang="ts">
// prompt
const emits = defineEmits(['onPrompt']);
const promptList = [
{
prompt: '今天气怎么样?',
},
{
prompt: '写一首好听的诗歌?',
},
]; /** 选中 prompt 点击 */
const handlerPromptClick = async (prompt: any) => {
emits('onPrompt', prompt.prompt);
};
</script>
<template>
<div class="chat-empty">
<!-- title -->
<div class="center-container">
<div class="title">芋道 AI</div>
<div class="role-list">
<div
class="role-item"
v-for="prompt in promptList"
:key="prompt.prompt"
@click="handlerPromptClick(prompt)"
>
{{ prompt.prompt }}
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.chat-empty {
position: relative;
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
height: 100%;
.center-container {
display: flex;
flex-direction: column;
justify-content: center;
.title {
font-size: 28px;
font-weight: bold;
text-align: center;
}
.role-list {
display: flex;
flex-flow: row wrap;
align-items: center;
justify-content: center;
width: 460px;
margin-top: 20px;
.role-item {
display: flex;
justify-content: center;
width: 180px;
margin: 10px;
line-height: 50px;
cursor: pointer;
border: 1px solid #e4e4e4;
border-radius: 10px;
}
.role-item:hover {
background-color: rgb(243 243 243 / 73%);
}
}
}
}
</style>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import { Skeleton } from 'ant-design-vue';
</script>
<template>
<div class="message-loading">
<Skeleton active />
</div>
</template>
<style scoped lang="scss">
.message-loading {
padding: 30px;
}
</style>

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import { Button } from 'ant-design-vue';
const emits = defineEmits(['onNewConversation']);
/** 新建 conversation 聊天对话 */
const handlerNewChat = () => {
emits('onNewConversation');
};
</script>
<template>
<div class="new-chat">
<div class="box-center">
<div class="tip">点击下方按钮开始你的对话吧</div>
<div class="btns">
<Button type="primary" round @click="handlerNewChat"></Button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.new-chat {
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
height: 100%;
.box-center {
display: flex;
flex-direction: column;
justify-content: center;
.tip {
font-size: 14px;
color: #858585;
}
.btns {
display: flex;
flex-direction: row;
justify-content: center;
margin-top: 20px;
}
}
}
</style>

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import { Button } from 'ant-design-vue';
//
defineProps({
categoryList: {
type: Array as PropType<string[]>,
required: true,
},
active: {
type: String,
required: false,
default: '全部',
},
});
//
const emits = defineEmits(['onCategoryClick']);
/** 处理分类点击事件 */
const handleCategoryClick = async (category: string) => {
emits('onCategoryClick', category);
};
</script>
<template>
<div class="category-list">
<div class="category" v-for="category in categoryList" :key="category">
<Button
size="small"
shape="round"
:type="category === active ? 'primary' : 'default'"
@click="handleCategoryClick(category)"
>
{{ category }}
</Button>
</div>
</div>
</template>
<style scoped lang="scss">
.category-list {
display: flex;
flex-flow: row wrap;
align-items: center;
.category {
display: flex;
flex-direction: row;
margin-right: 10px;
}
}
</style>

View File

@ -0,0 +1,183 @@
<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 { Button, Card, Dropdown, Menu } from 'ant-design-vue';
// tabs ref
//
const props = defineProps({
loading: {
type: Boolean,
required: true,
},
roleList: {
type: Array as PropType<AiModelChatRoleApi.ChatRoleVO[]>,
required: true,
},
showMore: {
type: Boolean,
required: false,
default: false,
},
});
//
const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage']);
const tabsRef = ref<any>();
/** 操作:编辑、删除 */
const handleMoreClick = async (data: any) => {
const type = data[0];
const role = data[1];
if (type === 'delete') {
emits('onDelete', role);
} else {
emits('onEdit', role);
}
};
/** 选中 */
const handleUseClick = (role: any) => {
emits('onUse', role);
};
/** 滚动 */
const handleTabsScroll = async () => {
if (tabsRef.value) {
const { scrollTop, scrollHeight, clientHeight } = tabsRef.value;
if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) {
await emits('onPage');
}
}
};
</script>
<template>
<div class="card-list" ref="tabsRef" @scroll="handleTabsScroll">
<div class="card-item" v-for="role in roleList" :key="role.id">
<Card
class="card"
:body-style="{
position: 'relative',
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-start',
width: '240px',
maxWidth: '240px',
padding: '15px 15px 10px',
}"
>
<!-- 更多操作 -->
<div class="more-container" v-if="showMore">
<Dropdown>
<Button type="text">
<span class="icon-[ant-design--more-outlined] text-2xl"></span>
</Button>
<template #overlay>
<Menu>
<Menu.Item @click="handleMoreClick(['edit', role])">
<div class="flex items-center">
<IconifyIcon icon="ep:edit" color="#787878" />
<span>编辑</span>
</div>
</Menu.Item>
<Menu.Item @click="handleMoreClick(['delete', role])">
<div class="flex items-center">
<IconifyIcon icon="ep:delete" color="red" />
<span style="color: red">编辑</span>
</div>
</Menu.Item>
</Menu>
</template>
</Dropdown>
</div>
<!-- 角色信息 -->
<div>
<img class="avatar" :src="role.avatar" />
</div>
<div class="right-container">
<div class="content-container">
<div class="title">{{ role.name }}</div>
<div class="description">{{ role.description }}</div>
</div>
<div class="btn-container">
<Button type="primary" size="small" @click="handleUseClick(role)">
使用
</Button>
</div>
</div>
</Card>
</div>
</div>
</template>
<style scoped lang="scss">
//
.card-list {
position: relative;
display: flex;
flex-flow: row wrap;
place-content: flex-start start;
align-items: start;
height: 100%;
padding: 0 25px;
padding-bottom: 140px;
overflow: auto;
.card {
position: relative;
display: inline-block;
margin-right: 20px;
margin-bottom: 20px;
border-radius: 10px;
.more-container {
position: absolute;
top: 0;
right: 12px;
}
.avatar {
width: 40px;
height: 40px;
overflow: hidden;
border-radius: 10px;
}
.right-container {
width: 100%;
margin-left: 10px;
//height: 100px;
.content-container {
height: 85px;
.title {
max-width: 140px;
font-size: 18px;
font-weight: bold;
color: #3e3e3e;
}
.description {
margin-top: 10px;
font-size: 14px;
color: #6a6a6a;
}
}
.btn-container {
display: flex;
flex-direction: row-reverse;
margin-top: 2px;
}
}
}
}
</style>

View File

@ -0,0 +1,287 @@
<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 { Button, Input, Layout, TabPane, Tabs } from 'ant-design-vue';
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 './RoleCategoryList.vue';
import RoleList from './RoleList.vue';
const router = useRouter(); //
const [Drawer] = useVbenDrawer({
title: '角色管理',
footer: false,
class: 'w-[754px]',
});
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.ChatRoleVO[]>([]); // my
const publicRoleParams = reactive({
pageNo: 1,
pageSize: 50,
});
const publicRoleList = ref<AiModelChatRoleApi.ChatRoleVO[]>([]); // public
const activeCategory = ref<string>('全部'); //
const categoryList = ref<string[]>([]); //
/** tabs 点击 */
const handleTabsClick = async (tab: any) => {
//
activeTab.value = tab;
//
await getActiveTabsRole();
};
/** 获取 my role 我的角色 */
const getMyRole = async (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 公共角色 */
const getPublicRole = async (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 角色 */
const getActiveTabsRole = async () => {
if (activeTab.value === 'my-role') {
myRoleParams.pageNo = 1;
await getMyRole();
} else {
publicRoleParams.pageNo = 1;
await getPublicRole();
}
};
/** 获取角色分类列表 */
const getRoleCategoryList = async () => {
categoryList.value = ['全部', ...(await getCategoryList())];
};
/** 处理分类点击 */
const handlerCategoryClick = async (category: string) => {
//
activeCategory.value = category;
//
await getActiveTabsRole();
};
const handlerAddRole = async () => {
formModalApi.setData({ formType: 'my-create' }).open();
};
/** 编辑角色 */
const handlerCardEdit = async (role: any) => {
formModalApi.setData({ formType: 'my-update', id: role.id }).open();
};
/** 添加角色成功 */
const handlerAddRoleSuccess = async () => {
//
await getActiveTabsRole();
};
/** 删除角色 */
const handlerCardDelete = async (role: any) => {
await deleteMy(role.id);
//
await getActiveTabsRole();
};
/** 角色分页:获取下一页 */
const handlerCardPage = async (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 角色:新建聊天对话 */
const handlerCardUse = async (role: any) => {
// 1.
const data: AiChatConversationApi.ChatConversationVO = {
roleId: role.id,
} as unknown as AiChatConversationApi.ChatConversationVO;
const conversationId = await createChatConversationMy(data);
// 2.
await router.push({
path: '/ai/chat',
query: {
conversationId,
},
});
};
/** 初始化 */
onMounted(async () => {
//
await getRoleCategoryList();
// role
await getActiveTabsRole();
});
</script>
<template>
<Drawer>
<Layout class="role-container">
<FormModal @success="handlerAddRoleSuccess" />
<Layout.Content class="role-main">
<div class="search-container">
<!-- 搜索按钮 -->
<Input.Search
:loading="loading"
v-model:value="search"
class="search-input"
placeholder="请输入搜索的内容"
@search="getActiveTabsRole"
/>
<Button
v-if="activeTab === 'my-role'"
type="primary"
@click="handlerAddRole"
class="ml-[20px]"
>
<IconifyIcon icon="ep:user" style="margin-right: 5px" />
添加角色
</Button>
</div>
<Tabs
v-model:value="activeTab"
class="tabs p-4"
@tab-click="handleTabsClick"
>
<TabPane key="my-role" class="role-pane" tab="我的角色">
<RoleList
:loading="loading"
:role-list="myRoleList"
:show-more="true"
@on-delete="handlerCardDelete"
@on-edit="handlerCardEdit"
@on-use="handlerCardUse"
@on-page="handlerCardPage('my')"
class="mt-[20px]"
/>
</TabPane>
<TabPane key="public-role" class="role-pane" tab="公共角色">
<RoleCategoryList
class="role-category-list"
:category-list="categoryList"
:active="activeCategory"
@on-category-click="handlerCategoryClick"
/>
<RoleList
:role-list="publicRoleList"
@on-delete="handlerCardDelete"
@on-edit="handlerCardEdit"
@on-use="handlerCardUse"
@on-page="handlerCardPage('public')"
class="mt-[20px]"
loading
/>
</TabPane>
</Tabs>
</Layout.Content>
</Layout>
</Drawer>
</template>
<style scoped lang="scss">
//
.role-container {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: hidden;
background-color: #fff;
.role-main {
position: relative;
flex: 1;
padding: 0;
margin: 0;
overflow: hidden;
.search-container {
position: absolute;
top: -5px;
right: 0;
z-index: 100;
margin: 20px 20px 0;
}
.search-input {
width: 240px;
}
.tabs {
position: relative;
height: 100%;
.role-category-list {
margin: 0 27px;
}
}
.role-pane {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
}
}
</style>

View File

@ -0,0 +1,79 @@
import type { VbenFormSchema } from '#/adapter/form';
import { getModelSimpleList } from '#/api/ai/model/model';
import { AiModelTypeEnum } from '#/utils';
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: {
controlsPosition: 'right',
placeholder: '请输入温度参数',
class: 'w-full',
precision: 2,
min: 0,
max: 2,
},
rules: 'required',
},
{
fieldName: 'maxTokens',
label: '回复数 Token 数',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入回复数 Token 数',
class: 'w-full',
min: 0,
max: 8192,
},
rules: 'required',
},
{
fieldName: 'maxContexts',
label: '上下文数量',
component: 'InputNumber',
componentProps: {
controlsPosition: 'right',
placeholder: '请输入上下文数量',
class: 'w-full',
min: 0,
max: 20,
},
rules: 'required',
},
];
}

View File

@ -1,28 +1,817 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
import type { AiChatMessageApi } from '#/api/ai/chat/message';
import { Button } from 'ant-design-vue';
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 { Button, Layout, message, Switch } from 'ant-design-vue';
import { getChatConversationMy } from '#/api/ai/chat/conversation';
import {
deleteByConversationId,
getChatMessageListByConversationId,
sendChatMessageStream,
} from '#/api/ai/chat/message';
import ConversationList from './components/conversation/ConversationList.vue';
import ConversationUpdateForm from './components/conversation/ConversationUpdateForm.vue';
import MessageList from './components/message/MessageList.vue';
import MessageListEmpty from './components/message/MessageListEmpty.vue';
import MessageLoading from './components/message/MessageLoading.vue';
import MessageNewConversation from './components/message/MessageNewConversation.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.ChatConversationVO | null>(
null,
); // Conversation
const conversationInProgress = ref(false); // true
//
const messageRef = ref();
const activeMessageList = ref<AiChatMessageApi.ChatMessageVO[]>([]); //
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); //
// Stream
const receiveMessageFullText = ref('');
const receiveMessageDisplayedText = ref('');
// =========== ===========
/** 获取对话信息 */
const getConversation = async (id: null | number) => {
if (!id) {
return;
}
const conversation: AiChatConversationApi.ChatConversationVO =
await getChatConversationMy(id);
if (!conversation) {
return;
}
activeConversation.value = conversation;
activeConversationId.value = conversation.id;
};
/**
* 点击某个对话
*
* @param conversation 选中的对话
* @return 是否切换成功
*/
const handleConversationClick = async (
conversation: AiChatConversationApi.ChatConversationVO,
) => {
//
if (conversationInProgress.value) {
alert('对话中,不允许切换!');
return false;
}
// id
activeConversationId.value = conversation.id;
activeConversation.value = conversation;
// message
await getMessageList();
//
scrollToBottom(true);
//
prompt.value = '';
return true;
};
/** 删除某个对话*/
const handlerConversationDelete = async (
delConversation: AiChatConversationApi.ChatConversationVO,
) => {
//
if (activeConversationId.value === delConversation.id) {
await handleConversationClear();
}
};
/** 清空选中的对话 */
const handleConversationClear = async () => {
//
if (conversationInProgress.value) {
alert('对话中,不允许切换!');
return false;
}
activeConversationId.value = null;
activeConversation.value = null;
activeMessageList.value = [];
};
const openChatConversationUpdateForm = async () => {
formModalApi.setData({ id: activeConversationId.value }).open();
};
const handleConversationUpdateSuccess = async () => {
//
await getConversation(activeConversationId.value);
};
/** 处理聊天对话的创建成功 */
const handleConversationCreate = async () => {
//
await conversationListRef.value.createConversation();
};
/** 处理聊天对话的创建成功 */
const handleConversationCreateSuccess = async () => {
//
prompt.value = '';
};
// =========== ===========
/** 获取消息 message 列表 */
const getMessageList = async () => {
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 消息 */
const handleMessageDelete = () => {
if (conversationInProgress.value) {
alert('回答中,不能删除!');
return;
}
// message
getMessageList();
};
/** 处理 message 清空 */
const handlerMessageClear = async () => {
if (!activeConversationId.value) {
return;
}
try {
//
await confirm('确认清空对话消息?');
//
await deleteByConversationId(activeConversationId.value);
// message
activeMessageList.value = [];
} catch {}
};
/** 回到 message 列表的顶部 */
const handleGoTopMessage = () => {
messageRef.value.handlerGoTop();
};
// =========== ===========
/** 处理来自 keydown 的发送消息 */
const handleSendByKeydown = async (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(); //
}
}
};
/** 处理来自【发送】按钮的发送消息 */
const handleSendByButton = () => {
doSendMessage(prompt.value?.trim() as string);
};
/** 处理 prompt 输入变化 */
const handlePromptInput = (event) => {
// 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);
};
const onCompositionstart = () => {
isComposing.value = true;
};
const onCompositionend = () => {
// console.log('...')
setTimeout(() => {
isComposing.value = false;
}, 200);
};
/** 真正执行【发送】消息操作 */
const doSendMessage = async (content: string) => {
//
if (content.length === 0) {
message.error('发送失败,原因:内容为空!');
return;
}
if (activeConversationId.value == null) {
message.error('还没创建对话,不能发送!');
return;
}
//
prompt.value = '';
//
await doSendMessageStream({
conversationId: activeConversationId.value,
content,
} as AiChatMessageApi.ChatMessageVO);
};
/** 真正执行【发送】消息操作 */
const doSendMessageStream = async (
userMessage: AiChatMessageApi.ChatMessageVO,
) => {
// 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,
createTime: new Date(),
} as AiChatMessageApi.ChatMessageVO,
{
id: -2,
conversationId: activeConversationId.value,
type: 'assistant',
content: '思考中...',
createTime: new Date(),
} as AiChatMessageApi.ChatMessageVO,
);
// 1.2
await nextTick();
await scrollToBottom(); //
// 1.3
textRoll();
// 2. event stream
let isFirstChunk = true; // chunk
await sendChatMessageStream(
userMessage.conversationId,
userMessage.content,
conversationInAbortController.value,
enableContext.value,
async (res: any) => {
const { code, data, msg } = JSON.parse(res.data);
if (code !== 0) {
alert(`对话异常! ${msg}`);
return;
}
//
if (data.receive.content === '') {
return;
}
// message
if (isFirstChunk) {
isFirstChunk = false;
//
activeMessageList.value.pop();
activeMessageList.value.pop();
//
activeMessageList.value.push(data.send, data.receive);
}
// debugger
receiveMessageFullText.value =
receiveMessageFullText.value + data.receive.content;
//
await scrollToBottom();
},
(error: any) => {
alert(`对话异常! ${error}`);
stopStream();
//
throw error;
},
() => {
stopStream();
},
);
} catch {}
};
/** 停止 stream 流式调用 */
const stopStream = async () => {
// tip stream message controller
if (conversationInAbortController.value) {
conversationInAbortController.value.abort();
}
// false
conversationInProgress.value = false;
};
/** 编辑 message设置为 prompt可以再次编辑 */
const handleMessageEdit = (message: AiChatMessageApi.ChatMessageVO) => {
prompt.value = message.content;
};
/** 刷新 message基于指定消息再次发起对话 */
const handleMessageRefresh = (message: AiChatMessageApi.ChatMessageVO) => {
doSendMessage(message.content);
};
// ============== =============
/** 滚动到 message 底部 */
const scrollToBottom = async (isIgnore?: boolean) => {
await nextTick();
if (messageRef.value) {
messageRef.value.scrollToBottom(isIgnore);
}
};
/** 自提滚动效果 */
const textRoll = async () => {
let index = 0;
try {
//
if (textRoleRunning.value) {
return;
}
//
textRoleRunning.value = true;
receiveMessageDisplayedText.value = '';
const task = async () => {
//
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>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/chat/index/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/chat/index/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<Layout class="ai-layout">
<!-- 左侧对话列表 -->
<ConversationList
:active-id="activeConversationId"
ref="conversationListRef"
@on-conversation-create="handleConversationCreateSuccess"
@on-conversation-click="handleConversationClick"
@on-conversation-clear="handleConversationClear"
@on-conversation-delete="handlerConversationDelete"
/>
<Layout class="detail-container">
<Layout.Header class="header">
<div class="title">
{{ activeConversation?.title ? activeConversation?.title : '对话' }}
<span v-if="activeMessageList.length > 0">
({{ activeMessageList.length }})
</span>
</div>
<div class="btns" v-if="activeConversation">
<Button
type="primary"
ghost
class="mr-[10px]"
size="small"
@click="openChatConversationUpdateForm"
>
<span v-html="activeConversation?.modelName"></span>
<IconifyIcon icon="ep:setting" class="ml-[10px]" />
</Button>
<Button
size="small"
class="btn mr-[10px]"
@click="handlerMessageClear"
>
<IconifyIcon
icon="heroicons-outline:archive-box-x-mark"
color="#787878"
/>
</Button>
<Button size="small" class="btn mr-[10px]">
<IconifyIcon icon="ep:download" color="#787878" />
</Button>
<Button
size="small"
class="btn mr-[10px]"
@click="handleGoTopMessage"
>
<IconifyIcon icon="ep:top" color="#787878" />
</Button>
</div>
</Layout.Header>
<Layout.Content class="main-container">
<div>
<div class="message-container">
<!-- 情况一消息加载中 -->
<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"
:list="messageList"
@on-delete-success="handleMessageDelete"
@on-edit="handleMessageEdit"
@on-refresh="handleMessageRefresh"
/>
</div>
</div>
</Layout.Content>
<Layout.Footer class="footer-container">
<form class="prompt-from">
<textarea
class="prompt-input"
v-model="prompt"
@keydown="handleSendByKeydown"
@input="handlePromptInput"
@compositionstart="onCompositionstart"
@compositionend="onCompositionend"
placeholder="问我任何问题...Shift+Enter 换行,按下 Enter 发送)"
></textarea>
<div class="prompt-btns">
<div>
<Switch v-model:checked="enableContext" />
<span class="ml-5px text-14px text-#8f8f8f">上下文</span>
</div>
<Button
type="primary"
@click="handleSendByButton"
:loading="conversationInProgress"
v-if="conversationInProgress === false"
>
{{ conversationInProgress ? '进行中' : '发送' }}
</Button>
<Button
danger
@click="stopStream()"
v-if="conversationInProgress === true"
>
停止
</Button>
</div>
</form>
</Layout.Footer>
</Layout>
</Layout>
<FormModal @success="handleConversationUpdateSuccess" />
</Page>
</template>
<style lang="scss" scoped>
.ai-layout {
position: absolute;
top: 0;
left: 0;
flex: 1;
width: 100%;
height: 100%;
}
.conversation-container {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 10px 10px 0;
.btn-new-conversation {
padding: 18px 0;
}
.search-input {
margin-top: 20px;
}
.conversation-list {
margin-top: 20px;
.conversation {
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 5px;
margin-top: 10px;
line-height: 30px;
cursor: pointer;
border-radius: 5px;
&.active {
background-color: #e6e6e6;
.button {
display: inline-block;
}
}
.title-wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.title {
max-width: 220px;
padding: 5px 10px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
white-space: nowrap;
}
.avatar {
display: flex;
flex-direction: row;
justify-items: center;
width: 28px;
height: 28px;
}
//
.button-wrapper {
right: 2px;
display: flex;
flex-direction: row;
justify-items: center;
color: #606266;
.el-icon {
margin-right: 5px;
}
}
}
}
//
.tool-box {
display: flex;
align-items: center;
justify-content: space-between;
line-height: 35px;
color: var(--el-text-color);
> div {
display: flex;
align-items: center;
padding: 0;
margin: 0;
color: #606266;
cursor: pointer;
> span {
margin-left: 5px;
}
}
}
}
//
.detail-container {
background: #fff;
.header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background: #fbfbfb;
box-shadow: 0 0 0 0 #dcdfe6;
.title {
font-size: 18px;
font-weight: bold;
}
.btns {
display: flex;
flex-direction: row;
justify-content: flex-end;
width: 300px;
//justify-content: space-between;
.btn {
padding: 0 10px;
}
}
}
}
// main
.main-container {
position: relative;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
.message-container {
position: absolute;
inset: 0;
padding: 0;
margin: 0;
overflow-y: hidden;
}
}
//
.footer-container {
display: flex;
flex-direction: column;
height: auto;
padding: 0;
margin: 0;
background-color: white;
.prompt-from {
display: flex;
flex-direction: column;
height: auto;
padding: 9px 10px;
margin: 10px 20px 20px;
border: 1px solid #e3e3e3;
border-radius: 10px;
}
.prompt-input {
box-sizing: border-box;
height: 80px;
padding: 0 2px;
overflow: auto;
resize: none;
//box-shadow: none;
border: none;
}
.prompt-input:focus {
outline: none;
}
.prompt-btns {
display: flex;
justify-content: space-between;
padding-top: 5px;
padding-bottom: 0;
}
}
</style>

View File

@ -1,28 +1,28 @@
<script lang="ts" setup>
import type { Nullable, Recordable } from '@vben/types';
import { ref, unref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import Mode from './mode/index.vue';
defineOptions({ name: 'Index' });
const listRef = ref<Nullable<{ generateMusic: (...args: any) => void }>>(null);
function generateMusic(args: { formData: Recordable<any> }) {
unref(listRef)?.generateMusic(args.formData);
}
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/music/index/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/music/index/index.vue
代码pull request 贡献给我们
</Button>
<div class="flex h-full items-stretch">
<!-- 模式 -->
<Mode class="flex-none" @generate-music="generateMusic" />
<!-- 音频列表 -->
<List ref="listRef" class="flex-auto" />
</div>
</Page>
</template>

View File

@ -0,0 +1,70 @@
<script lang="ts" setup>
import { reactive } from 'vue';
import { Select, Switch, Textarea } from 'ant-design-vue';
import Title from '../title/index.vue';
defineOptions({ name: 'Desc' });
const formData = reactive({
desc: '',
pure: false,
version: '3',
});
defineExpose({
formData,
});
</script>
<template>
<div>
<Title
title="音乐/歌词说明"
desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲"
>
<Textarea
v-model:value="formData.desc"
:autosize="{ minRows: 6, maxRows: 6 }"
:maxlength="1200"
:show-count="true"
placeholder="一首关于糟糕分手的欢快歌曲"
/>
</Title>
<Title title="纯音乐" class="mt-[20px]" desc="创建一首没有歌词的歌曲">
<template #extra>
<Switch v-model:checked="formData.pure" size="small" />
</template>
</Title>
<Title
title="版本"
desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲"
>
<Select
v-model:value="formData.version"
class="w-full"
placeholder="请选择"
>
<Select.Option
v-for="item in [
{
value: '3',
label: 'V3',
},
{
value: '2',
label: 'V2',
},
]"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</Select.Option>
</Select>
</Title>
</div>
</template>

View File

@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { Nullable, Recordable } from '@vben/types';
import { ref, unref } from 'vue';
import { Button, Card, Radio } from 'ant-design-vue';
import desc from './desc.vue';
import lyric from './lyric.vue';
defineOptions({ name: 'Index' });
const emits = defineEmits(['generateMusic']);
const generateMode = ref('lyric');
const modeRef = ref<Nullable<{ formData: Recordable<any> }>>(null);
/*
*@Description: 根据信息生成音乐
*@MethodAuthor: xiaohong
*@Date: 2024-06-27 16:40:16
*/
function generateMusic() {
emits('generateMusic', { formData: unref(modeRef)?.formData });
}
</script>
<template>
<Card class="mb-[0!important] h-full w-[300px]">
<Radio.Group v-model:value="generateMode" class="mb-[15px]">
<Radio.Button value="desc"> 描述模式 </Radio.Button>
<Radio.Button value="lyric"> 歌词模式 </Radio.Button>
</Radio.Group>
<!-- 描述模式/歌词模式 切换 -->
<component :is="generateMode === 'desc' ? desc : lyric" ref="modeRef" />
<Button type="primary" shape="round" class="w-full" @click="generateMusic">
创作音乐
</Button>
</Card>
</template>

View File

@ -0,0 +1,103 @@
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { Button, Input, Select, Space, Tag, Textarea } from 'ant-design-vue';
import Title from '../title/index.vue';
defineOptions({ name: 'Lyric' });
const tags = ['rock', 'punk', 'jazz', 'soul', 'country', 'kidsmusic', 'pop'];
const showCustom = ref(false);
const formData = reactive({
lyric: '',
style: '',
name: '',
version: '',
});
defineExpose({
formData,
});
</script>
<template>
<div class="">
<Title title="歌词" desc="自己编写歌词或使用Ai生成歌词两节/8行效果最佳">
<Textarea
v-model:value="formData.lyric"
:autosize="{ minRows: 6, maxRows: 6 }"
:maxlength="1200"
:show-count="true"
placeholder="请输入您自己的歌词"
/>
</Title>
<Title title="音乐风格">
<Space class="flex-wrap">
<Tag v-for="tag in tags" :key="tag" class="mb-[8px]">
{{ tag }}
</Tag>
</Space>
<Button
:type="showCustom ? 'primary' : 'default'"
shape="round"
size="small"
class="mb-[6px]"
@click="showCustom = !showCustom"
>
自定义风格
</Button>
</Title>
<Title
v-show="showCustom"
desc="描述您想要的音乐风格Suno无法识别艺术家的名字但可以理解流派和氛围"
class="mt-[12px]"
>
<Textarea
v-model="formData.style"
:autosize="{ minRows: 4, maxRows: 4 }"
:maxlength="256"
show-count
placeholder="输入音乐风格(英文)"
/>
</Title>
<Title title="音乐/歌曲名称">
<Input
class="w-full"
v-model="formData.name"
placeholder="请输入音乐/歌曲名称"
/>
</Title>
<Title title="版本">
<Select
v-model:value="formData.version"
class="w-full"
placeholder="请选择"
>
<Select.Option
v-for="item in [
{
value: '3',
label: 'V3',
},
{
value: '2',
label: 'V2',
},
]"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</Select.Option>
</Select>
</Title>
</div>
</template>

View File

@ -0,0 +1,27 @@
<script lang="ts" setup>
defineOptions({ name: 'Index' });
defineProps({
title: {
type: String,
default: '',
},
desc: {
type: String,
default: '',
},
});
</script>
<template>
<div class="mb-[12px]">
<div class="flex items-center justify-between" style="color: #303133">
<span>{{ title }}</span>
<slot name="extra"></slot>
</div>
<div class="my-[8px] text-[12px]" style="color: #909399">
{{ desc }}
</div>
<slot></slot>
</div>
</template>