feat(ai): 添加 AI 聊天功能
- 新增 AI 聊天对话和消息相关 API - 实现聊天界面,包括对话列表、消息列表、发送消息等功能 - 添加音乐生成功能的初始框架pull/145/head
parent
d2fbb5a18b
commit
4596cd9fa5
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询思维导图分页
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取写作列表
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">
|
||||
<!-- 靠左 message:system、assistant 类型 -->
|
||||
<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>
|
||||
<!-- 靠右 message:user 类型 -->
|
||||
<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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue