Commit b4b853cf authored by tyyin lan's avatar tyyin lan

feat: 首页应用对话

parent 1008706c
......@@ -15,4 +15,5 @@
"src/locales",
"src/locales/langs"
],
}
"i18n-ally.sourceLanguage": "zh-hk",
}
\ No newline at end of file
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link rel="stylesheet" href="//at.alicdn.com/t/c/font_4711453_vrea727r3s.css" />
<title>SuperLink</title>
</head>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="stylesheet" href="//at.alicdn.com/t/c/font_4711453_xv7qlvxz82a.css">
<title>SuperLink</title>
</head>
<body>
<div id="app">
<style>
.blob-wrapper {
width: 100%;
padding-top: 30vh;
display: flex;
justify-content: center;
}
.blob {
width: 176px;
height: 176px;
display: grid;
background: #fff;
filter: blur(7px) contrast(10);
padding: 17.6px;
mix-blend-mode: darken;
}
.blob:before {
content: '';
margin: auto;
width: 52.8px;
height: 52.8px;
border-radius: 50%;
color: #ccc;
background: currentColor;
box-shadow:
-52.8px 0,
52.8px 0,
0 52.8px,
0 -52.8px;
animation: blob-031hhghg 1s infinite alternate;
}
<body>
<div id="app">
<style>
.blob-wrapper {
width: 100%;
padding-top: 30vh;
display: flex;
justify-content: center;
}
@keyframes blob-031hhghg {
.blob {
width: 176px;
height: 176px;
display: grid;
background: #fff;
filter: blur(7px) contrast(10);
padding: 17.6px;
mix-blend-mode: darken;
}
90%,
100% {
.blob:before {
content: '';
margin: auto;
width: 52.8px;
height: 52.8px;
border-radius: 50%;
color: #ccc;
background: currentColor;
box-shadow:
-17.6px 0,
17.6px 0,
0 17.6px,
0 -17.6px;
transform: rotate(180deg);
-52.8px 0,
52.8px 0,
0 52.8px,
0 -52.8px;
animation: blob-031hhghg 1s infinite alternate;
}
@keyframes blob-031hhghg {
90%,
100% {
box-shadow:
-17.6px 0,
17.6px 0,
0 17.6px,
0 -17.6px;
transform: rotate(180deg);
}
}
}
</style>
<div class="blob-wrapper">
<div class="blob"></div>
</style>
<div class="blob-wrapper">
<div class="blob"></div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
......@@ -26,12 +26,14 @@
"clipboardy": "^4.0.0",
"dayjs": "^1.11.13",
"highlight.js": "^11.10.0",
"lodash-es": "^4.17.21",
"markdown-it": "^14.1.0",
"markdown-it-link-attributes": "^4.0.1",
"mitt": "^3.0.1",
"nanoid": "^5.0.7",
"pinia": "^2.2.2",
"spark-md5": "^3.0.2",
"type-fest": "^4.26.1",
"validator": "^13.12.0",
"vue": "^3.5.12",
"vue-i18n": "^9.14.0",
......@@ -42,6 +44,7 @@
"@commitlint/config-conventional": "^19.5.0",
"@commitlint/types": "^19.5.0",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
"@types/markdown-it-link-attributes": "^3.0.5",
"@types/node": "^20.16.5",
......@@ -80,6 +83,7 @@
"unplugin-vue-components": "^0.26.0",
"vite": "^5.4.6",
"vite-plugin-checker": "^0.7.2",
"vite-svg-loader": "^5.1.0",
"vue-eslint-parser": "^9.4.3",
"vue-tsc": "^2.0.29"
},
......
This diff is collapsed.
import { request } from '@/utils/request'
export function fetchAgentApplicationSelectList<T>() {
return request.post<T>('/agentApplicationRest/getDefaultList.json', {
query: '',
pagingInfo: { pageNo: 1, pageSize: 99999 },
})
}
export function fetchCreateSessionId<T>() {
return request.post<T>('/agentApplicationRest/createDialogues.json')
}
export function fetchRecommendQuestionList<T>() {
return request.post<T>('/agentApplicationRest/getRecommendQuestions.json')
}
export function fetchSessionHistoryRecordList<T>() {
return request.post<T>('/agentApplicationRest/getUserDialogues.json')
}
export function fetchHistoryRecordDelete<T>(recordIdList: string[]) {
return request.post<T>('/agentApplicationRest/batchCloseDialogues.json', recordIdList)
}
export function fetchMessageRecordList<T>(dialogueId: string) {
return request.post<T>(`/agentApplicationRest/getDialogueContext.json?dialogueId=${dialogueId}`)
}
<?xml version="1.0" encoding="UTF-8"?>
<svg width="17px" height="16px" viewBox="0 0 17 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组</title>
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="首页" transform="translate(-1270.000000, -935.000000)" fill="#FFFFFF" fill-rule="nonzero">
<g id="编组" transform="translate(1270.885714, 935.000000)">
<path d="M0.284887293,6.33441056 L15.2050367,0.036136317 C15.3669903,-0.0321398353 15.553128,-0.00221881558 15.68614,0.113649831 C15.8191094,0.229485731 15.8762478,0.411370061 15.8338211,0.583751221 L12.1172138,15.6442655 C12.078375,15.8010947 11.9626991,15.9266967 11.810949,15.9768106 C11.6593034,16.0270136 11.4927905,15.994482 11.3702846,15.8910638 L8.14330446,13.1672469 C7.95875267,13.0116174 7.68757454,13.0244694 7.51808821,13.1965655 L5.86287081,14.8721426 C5.73018867,15.0057705 5.531264,15.0455987 5.35837641,14.9731511 C5.18549553,14.9007916 5.07245837,14.7302939 5.07173636,14.5408024 L5.07173636,10.9992783 C5.07173636,10.8745739 5.12050085,10.7550906 5.20752366,10.6667332 L11.9386089,3.84795374 L3.99197958,9.88657779 C3.81991626,10.0171057 3.58164424,10.0108805 3.41671719,9.87131606 L0.166544205,7.12862286 C0.0422156661,7.02342449 -0.0189472668,6.86015211 0.00518528765,6.69788066 C0.0294233577,6.53558161 0.135432294,6.39782401 0.284887293,6.33441056 Z" id="路径"></path>
</g>
</g>
</g>
</svg>
\ No newline at end of file
......@@ -3,6 +3,7 @@ import { BASE_URLS } from '@/config/base-url'
import { useUserStore } from '@/store/modules/user'
import { useRouter } from 'vue-router'
import { router } from '@/router'
import { debounce } from 'lodash-es'
interface PagingInfoParams {
pageNo: number
......@@ -16,16 +17,34 @@ export interface Response<T> {
pagingInfo?: PagingInfoParams & { totalPages: number; totalRows: number }
}
function handleLogout() {
const currentRoute = router.currentRoute.value
// function handleLogout() {
// const currentRoute = router.currentRoute.value
router.replace({ name: 'Login', query: { redirect: encodeURIComponent(currentRoute.fullPath) } })
useUserStore().logout()
window.$message.warning('身份已过期,请重新登录')
}
// router.replace({ name: 'Login', query: { redirect: encodeURIComponent(currentRoute.fullPath) } })
// useUserStore().logout()
// window.$message.warning('身份已过期,请重新登录')
// }
const ENV = import.meta.env.VITE_APP_ENV
const handleLogout = debounce(
() => {
const currentRoute = router.currentRoute.value
if (currentRoute.name === 'Login') {
return
}
router.replace({ name: 'Login', query: { redirect: encodeURIComponent(currentRoute.fullPath) } })
useUserStore().logout()
window.$message.warning('身份已过期,请重新登录')
},
2000,
{ leading: true, trailing: false },
)
const service = axios.create({
baseURL: `${BASE_URLS[window.ENV || 'DEV']}/api/rest`,
baseURL: `${BASE_URLS[ENV || 'DEV']}/api/rest`,
timeout: 7000,
headers: {
'Content-Type': 'application/json',
......
<script setup lang="ts">
import { fetchRecommendQuestionList } from '@/apis/home-agent'
import { nanoid } from 'nanoid'
import { computed, ref, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { debounce } from 'lodash-es'
interface Props {
messageListLength: number
}
interface RecommendQuestionItem {
id: string
content: string
}
const props = defineProps<Props>()
const questionContent = defineModel<string>('questionContent', { required: true })
const { t } = useI18n()
const recommendQuestionList = ref<RecommendQuestionItem[]>([])
const isShowAgentAbout = computed(() => {
return props.messageListLength === 0
})
;(function () {
getRecommendQuestionList()
})()
function getRecommendQuestionList() {
return fetchRecommendQuestionList<string[]>().then((res) => {
const recommendQuestionListDraft: RecommendQuestionItem[] = []
res.data.forEach((content) => {
recommendQuestionListDraft.push({ id: nanoid(), content })
})
recommendQuestionList.value = recommendQuestionListDraft
})
}
function handleQuestionClick(question: string) {
questionContent.value = question
}
const handleRecommendQuestionListUpdate = debounce(
() => {
const loadingCtl = window.$message.loading('更新中...')
getRecommendQuestionList().then(() => {
loadingCtl.destroy()
nextTick(() => {
window.$message.success('更新成功')
})
})
},
700,
{ leading: true, trailing: false },
)
</script>
<template>
<div class="flex items-center">
<img class="h-[70px] w-[70px] rounded-full" src="@/assets/images/agent-avatar.png" alt="代理人頭像" />
<div :class="isShowAgentAbout ? 'flex flex-1 flex-col overflow-hidden overflow-y-auto' : ''">
<Transition name="agent-about" mode="out-in">
<div v-if="isShowAgentAbout" class="flex flex-1 flex-col overflow-hidden overflow-y-auto">
<div class="flex items-center pr-[10px]">
<img class="h-[70px] w-[70px] rounded-full" src="@/assets/images/home/agent-avatar.png" alt="代理人頭像" />
<div class="ml-[20px]">
<h2 class="font-600 mb-[14px] text-[26px]">{{ t('home_module.agent_welcome_message') }}</h2>
<div class="text-theme-color text-[18px]">{{ t('home_module.agent_description') }}</div>
</div>
<div class="ml-[20px]">
<h2 class="font-600 mb-[14px] text-[26px]">{{ t('home_module.agent_welcome_message') }}</h2>
<div class="text-theme-color text-[18px]">{{ t('home_module.agent_description') }}</div>
</div>
</div>
<div class="mt-[39px] flex flex-1 flex-col overflow-hidden overflow-y-auto">
<h3 class="text-[14px] text-[#999]">推荐问题:</h3>
<div class="flex-1 overflow-hidden overflow-y-auto py-[10px]">
<n-scrollbar>
<ul class="select-none pr-[10px]">
<template v-if="recommendQuestionList.length">
<li
v-for="questionItem in recommendQuestionList"
:key="questionItem.id"
class="mt-[14px] w-fit cursor-pointer rounded-[20px] bg-[#E1E1FC] px-[12px] py-[10px] transition first:mt-[4px] hover:bg-[#D1D1EB]"
@click="handleQuestionClick(questionItem.content)"
>
{{ questionItem.content }}
</li>
</template>
<template v-else>
<n-skeleton class="mt-[4px]" height="41px" width="52%" round />
<n-skeleton class="mt-[14px]" height="41px" width="72%" round />
<n-skeleton class="mt-[14px]" height="41px" width="70%" round />
</template>
<li class="mt-[10px] pl-[20px] text-[12px]">
<span
class="group cursor-pointer text-[#0B7DFF] transition hover:text-[#096EE0]"
@click="handleRecommendQuestionListUpdate"
>
<i
class="iconfont icon-huanyihuan group-active:rotate-360 mr-[2px] inline-block text-[11px] transition-[rotate] duration-150 ease-in-out"
></i>
<span>换一换</span>
</span>
</li>
</ul>
</n-scrollbar>
</div>
</div>
</div>
<div v-else class="flex-center flex">
<div class="font-600 flex items-center rounded-[31px] bg-[#ECEFFF] px-[38px] py-[11px] text-[18px]">
<div class="bg-px-home-cuixiang_icon-png mr-[6px] h-[28px] w-[28px] bg-no-repeat"></div>
<span>萃想助手</span>
</div>
</div>
</Transition>
</div>
<!-- <div v-if="isShowAgentAbout" class="flex flex-1 flex-col overflow-hidden overflow-y-auto">
<div class="flex items-center pr-[10px]">
<img class="h-[70px] w-[70px] rounded-full" src="@/assets/images/home/agent-avatar.png" alt="代理人頭像" />
<div class="ml-[20px]">
<h2 class="font-600 mb-[14px] text-[26px]">{{ t('home_module.agent_welcome_message') }}</h2>
<div class="text-theme-color text-[18px]">{{ t('home_module.agent_description') }}</div>
</div>
</div>
<div class="mt-[39px] flex flex-1 flex-col overflow-hidden overflow-y-auto">
<h3 class="text-[14px] text-[#999]">推荐问题:</h3>
<div class="flex-1 overflow-hidden overflow-y-auto py-[10px]">
<n-scrollbar>
<ul class="select-none pr-[10px]">
<li
v-for="item in 3"
:key="item"
class="mt-[14px] w-fit cursor-pointer rounded-[20px] bg-[#E1E1FC] px-[12px] py-[10px] transition first:mt-[4px] hover:bg-[#D1D1EB]"
>
如果视图离开屏幕,则可将其停用,并可随时重新用于新的可见项目。如果视图离开屏幕,则可将其停用,并可随时重新用于新的可见项目。如果视图离开屏幕,则可将其停用,并可随时重新用于新的可见项目。
</li>
<li class="mt-[10px] cursor-pointer pl-[20px] text-[12px] text-[#0B7DFF] transition hover:text-[#096EE0]">
<i class="iconfont icon-huanyihuan mr-[2px] text-[11px]"></i>
<span>换一换</span>
</li>
</ul>
</n-scrollbar>
</div>
</div>
</div>
<div v-else class="flex-center flex">
<div class="font-600 flex items-center rounded-[31px] bg-[#ECEFFF] px-[38px] py-[11px] text-[18px]">
<div class="bg-px-home-cuixiang_icon-png mr-[6px] h-[28px] w-[28px] bg-no-repeat"></div>
<span>萃想助手</span>
</div>
</div> -->
</template>
<style lang="scss" scoped>
.agent-about-enter-active,
.agent-about-leave-active {
transition-timing-function: ease-in-out;
transition-duration: 0.2s;
transition-property: opacity;
}
.agent-about-enter-from,
.agent-about-leave-to {
opacity: 0;
}
</style>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import type { AgentApplicationRecordItem, MessageItemInterface } from '../types'
import { fetchAgentApplicationSelectList } from '@/apis/home-agent'
import { nanoid } from 'nanoid'
import fetchEventStreamSource from '../utils/fetch-event-stream-source'
import { throttle } from 'lodash-es'
interface Props {
currentSessionId: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
messageListScrollToBottom: []
addMessageItem: [messageId: string, messageItem: MessageItemInterface]
updateSpecifyMessageItem: [messageId: string, newMessageItem: Partial<MessageItemInterface>]
deleteMessageItem: [messageId: string]
createNewSession: []
historyRecordListUpdate: []
}>()
const questionContent = defineModel<string>('questionContent', { required: true })
const currentAgentApplication = defineModel<AgentApplicationRecordItem>('currentAgentApplication', { required: true })
const isAgentResponding = defineModel<boolean>('isAgentResponding', { required: true })
const isShowApplicationSelectMenu = ref(false)
const agentApplicationSelectList = ref<AgentApplicationRecordItem[]>([])
const currentLatestMessageItemKeyMap = ref(new Map<'assistant' | 'user', string>())
const isQuestionSubmitBtnDisabled = computed(() => {
return questionContent.value.trim().length === 0 || isAgentResponding.value
})
;(function () {
getAgentApplicationSelectList()
})()
const messageListScrollToBottomThrottle = throttle(() => {
emit('messageListScrollToBottom')
}, 1000)
function getAgentApplicationSelectList() {
fetchAgentApplicationSelectList<AgentApplicationRecordItem[]>().then((res) => {
agentApplicationSelectList.value = res.data
currentAgentApplication.value = res.data[0]
})
}
function handleApplicationSelectMenuSwitchShow() {
isShowApplicationSelectMenu.value = !isShowApplicationSelectMenu.value
}
function handleApplicationChange(agentApplicationItem: AgentApplicationRecordItem) {
currentAgentApplication.value = agentApplicationItem
}
function handleCreateNewSession() {
emit('createNewSession')
}
function questionSubmit() {
const latestUserMessageKey = nanoid()
const latestAssistantMessageKey = nanoid()
currentLatestMessageItemKeyMap.value.set('user', latestUserMessageKey)
currentLatestMessageItemKeyMap.value.set('assistant', latestAssistantMessageKey)
emit('addMessageItem', latestUserMessageKey, {
role: 'user',
agentId: '',
content: questionContent.value.trim(),
timestamp: Date.now(),
isAnswerLoading: false,
})
nextTick(() => {
emit('addMessageItem', latestAssistantMessageKey, {
role: 'assistant',
agentId: '',
content: '',
timestamp: Date.now(),
isAnswerLoading: true,
})
emit('messageListScrollToBottom')
})
isAgentResponding.value = true
let isFirstClip = true
let messageContent = ''
fetchEventStreamSource(
'/agentApplicationRest/callAgentApplication.json',
{
dialogsId: props.currentSessionId, //会话ID
agentId: currentAgentApplication.value.agentId, //应用ID
input: questionContent.value.trim(), //提问文本
},
{
onmessage: (message) => {
messageContent += message
if (isFirstClip) {
emit('updateSpecifyMessageItem', currentLatestMessageItemKeyMap.value.get('assistant')!, {
content: message,
isAnswerLoading: false,
})
isFirstClip = false
} else {
emit('updateSpecifyMessageItem', currentLatestMessageItemKeyMap.value.get('assistant')!, {
content: messageContent,
})
}
messageListScrollToBottomThrottle()
},
onend: () => {
console.log('🐞🐞🐞🐞🐞🐞响应结束🐞🐞🐞🐞🐞🐞')
},
onclose: () => {
isAgentResponding.value = false
nextTick(() => {
emit('historyRecordListUpdate')
})
},
onerror: (err) => {
emit('deleteMessageItem', currentLatestMessageItemKeyMap.value.get('assistant')!)
console.log('🐛🐛🐛🐛🐛🐛响应错误🐛🐛🐛🐛🐛🐛')
console.log(err)
},
},
)
questionContent.value = ''
}
function handleQuestionSubmitEnter(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
if (questionContent.value.trim().length > 0) {
questionSubmit()
}
}
}
</script>
<template>
<div class="mt-auto pr-[10px]">
<div class="mb-[10px] flex select-none justify-between">
<!-- <div
class="relative box-border flex h-[34px] w-[145px] cursor-pointer items-center rounded-[10px] border border-[#9EA3FF] px-[12px]"
>
<div class="flex w-full items-center justify-between">
<div class="flex items-center">
<div class="bg-px-home-cuixiang_icon-png mr-[5px] h-[16px] w-[16px]"></div>
<div class="text-[14px]">萃想AI</div>
</div>
<Up theme="outline" size="18" fill="#333" :stroke-width="3" />
</div>
<div
class="absolute -top-[10px] left-1/2 w-[200px] -translate-x-1/2 -translate-y-full rounded-[10px] bg-[#fff] p-[6px] shadow-md"
>
hello
</div>
</div> -->
<div class="relative">
<n-button
class="application-select-btn !h-[34px] !rounded-[10px] !p-0"
@click="handleApplicationSelectMenuSwitchShow"
@blur="() => (isShowApplicationSelectMenu = false)"
>
<div class="box-border flex !w-[160px] w-full items-center justify-between px-[12px]">
<div class="mr-[5px] flex flex-1 items-center overflow-hidden">
<div
class="mr-[10px] h-[16px] w-[16px] rounded-full bg-cover"
:class="{ 'bg-px-home-cuixiang_icon-png': !currentAgentApplication.agentAvatar }"
:style="{ backgroundImage: `url(${currentAgentApplication.agentAvatar})` }"
></div>
<div class="flex-1 truncate text-start text-[14px]">{{ currentAgentApplication.agentTitle || '-' }}</div>
</div>
<i
class="iconfont icon-left rotate-90 text-[12px] transition-[rotate] duration-300 ease-in-out"
:class="{ '!rotate-270': isShowApplicationSelectMenu }"
></i>
</div>
</n-button>
<Transition name="application-select-menu">
<ul
v-show="isShowApplicationSelectMenu"
class="absolute -top-[10px] left-1/2 w-[200px] -translate-x-1/2 -translate-y-full rounded-[6px] bg-[#fff] p-[10px] pb-[4px] pr-0 shadow-md"
>
<n-virtual-list
style="max-height: 200px"
:item-size="35"
:items="agentApplicationSelectList"
key-field="agentId"
items-style="padding-right: 10px;"
>
<template #default="{ item }">
<li
class="relative mb-[6px] flex cursor-pointer items-center overflow-hidden rounded-[4px] bg-[#f3f3f5] px-[10px] py-[4px] transition hover:bg-[#E7E7E7]"
@click="handleApplicationChange(item)"
>
<div
class="mr-[10px] h-[16px] w-[16px] rounded-full bg-cover"
:class="{ 'bg-px-home-cuixiang_icon-png': !item.agentAvatar }"
:style="{ backgroundImage: `url(${item.agentAvatar})` }"
></div>
<div class="flex-1 truncate">{{ item.agentTitle }}</div>
<i
v-if="item.agentId === currentAgentApplication.agentId"
class="iconfont icon-xuanze absolute bottom-0 right-0 text-[14px] text-[#777ef9]"
></i>
</li>
</template>
</n-virtual-list>
</ul>
</Transition>
</div>
<n-button class="application-select-btn !h-[34px] !rounded-[10px] !p-0" @click="handleCreateNewSession">
<div class="box-border flex w-full items-center justify-between px-[12px]">
<i class="iconfont icon-session mr-[5px] text-[14px]"></i>
<span class="text-[14px]">发起新会话</span>
</div>
</n-button>
</div>
<div>
<n-input
v-model:value.trim="questionContent"
class="content-input"
type="textarea"
:autosize="{ minRows: 5, maxRows: 5 }"
placeholder="请输入问题"
@keydown="handleQuestionSubmitEnter"
>
<template #suffix>
<div class="flex h-full items-end pb-[10px]">
<n-button type="primary" :disabled="isQuestionSubmitBtnDisabled"
><i class="iconfont icon-send-icon"></i
></n-button>
</div>
</template>
</n-input>
</div>
<div class="mt-[20px] text-center text-[13px] text-[#999]">以上内容均由AI生成,仅供参考</div>
</div>
</template>
<style lang="scss" scoped>
.content-input {
:deep(.n-input__border) {
border-color: #9ea3ff;
}
}
:deep(.application-select-btn) {
.n-button__border {
border-color: #9ea3ff;
}
}
.application-select-menu-enter-active,
.application-select-menu-leave-active {
transition-timing-function: ease-in-out;
transition-duration: 0.2s;
transition-property: opacity, scale;
transform-origin: left center;
}
.application-select-menu-enter-from,
.application-select-menu-leave-to {
opacity: 0;
scale: 0.8;
}
</style>
<script setup lang="ts">
interface Props {
activeColor: string
}
const { activeColor = '#fff' } = defineProps<Props>()
</script>
<template>
<div class="loader" />
</template>
<style lang="scss" scoped>
.loader {
width: 6px;
aspect-ratio: 1;
border-radius: 50%;
animation: l5 1s infinite linear alternate;
}
@keyframes l5 {
0% {
background: v-bind('activeColor');
box-shadow:
13px 0 v-bind('activeColor'),
-13px 0 #0002;
}
33% {
background: #0002;
box-shadow:
13px 0 v-bind('activeColor'),
-13px 0 #0002;
}
66% {
background: #0002;
box-shadow:
13px 0 #0002,
-13px 0 v-bind('activeColor');
}
100% {
background: v-bind('activeColor');
box-shadow:
13px 0 #0002,
-13px 0 v-bind('activeColor');
}
}
</style>
<script setup lang="ts">
import { computed, readonly } from 'vue'
import type { MessageItemInterface } from '../types'
import { useUserStore } from '@/store/modules/user'
import MessageBubbleLoading from './message-bubble-loading.vue'
interface Props {
messageItem: MessageItemInterface
}
const props = defineProps<Props>()
const userStore = useUserStore()
const agentAvatarUrl = readonly({ url: 'https://gsst-poe-sit.gz.bcebos.com/icon/agent-avatar.png' })
const isAgentMessage = computed(() => {
return props.messageItem.role === 'assistant'
})
</script>
<template>
<div class="mb-[20px] last:mb-0">
<div class="flex">
<img
class="h-[36px] w-[36px]"
:src="isAgentMessage ? agentAvatarUrl.url : userStore.userInfo.avatarUrl"
alt="Avatar"
/>
<div class="ml-[11px]">
<div class="mb-[7px] text-[12px] text-[#999]">
{{ isAgentMessage ? '萃想AI' : userStore.userInfo.nickName }}
</div>
<div
class="box-content min-h-[21px] min-w-[100px] rounded-[10px] border border-[#9EA3FF] px-[15px] py-[14px] text-justify"
:class="{
'bg-[#777EF9]': isAgentMessage,
'text-[#fff]': isAgentMessage,
}"
>
<MarkdownRender
v-if="!messageItem.isAnswerLoading"
:raw-text-content="messageItem.content ? messageItem.content : '[空内容]'"
:color="isAgentMessage ? '#fff' : '#192338'"
/>
<div v-else class="flex h-[21px] items-center justify-center">
<MessageBubbleLoading :active-color="isAgentMessage ? '#fff' : '#192338'" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MessageItem from './message-item.vue'
import type { MessageItemInterface } from '../types'
import { useTemplateRef } from 'vue'
import { ScrollbarInst } from 'naive-ui'
interface Props {
messageList: Map<string, MessageItemInterface>
}
defineProps<Props>()
defineExpose({
scrollToBottom,
})
const scrollbarRef = useTemplateRef<ScrollbarInst | null>('scrollbarRef')
function scrollToBottom() {
if (scrollbarRef.value) {
scrollbarRef.value.scrollTo({ top: 999999999, behavior: 'smooth' })
}
}
</script>
<template>
<div class="flex-1 overflow-hidden overflow-y-auto py-[20px]">
<n-scrollbar ref="scrollbarRef">
<div class="pr-[10px]">
<MessageItem v-for="[key, messageItem] in messageList" :key="key" :message-item="messageItem" />
</div>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
title?: string
content: string
}
const { title = '提示', content = '' } = defineProps<Props>()
const isShowModal = ref(false)
let _modalStatusResolve = (_value?: any) => {}
let _modalStatusReject = (_reason?: any) => {}
function handleCancel() {
isShowModal.value = false
_modalStatusReject(new Error('cancel show modal'))
}
function handleConfirm() {
isShowModal.value = false
_modalStatusResolve(true)
}
function handleShowModal() {
isShowModal.value = true
return new Promise((resolve, reject) => {
_modalStatusResolve = resolve
_modalStatusReject = reject
})
}
defineExpose({
showModal: handleShowModal,
})
</script>
<template>
<n-modal v-model:show="isShowModal">
<div class="min-w-[420px] max-w-[600px] rounded-[10px] bg-[#fff] p-[30px]">
<div>
<h2>
<i class="iconfont icon-tishi text-[18px] text-[#f25744]"></i>
<span class="font-600 ml-[5px] text-[18px]">{{ title }}</span>
</h2>
<div class="mt-[20px] indent-4 text-[16px]">{{ content }}</div>
</div>
<div class="mt-[50px] text-end">
<n-button color="#F5F5F5" round class="!px-[34px] !py-[10px] !text-[14px] !text-[#333]" @click="handleCancel">
取消
</n-button>
<n-button color="#6F77FF" round class="!ml-[12px] !px-[34px] !py-[10px] !text-[14px]" @click="handleConfirm">
确定
</n-button>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { onMounted, ref, useTemplateRef, watch } from 'vue'
import AgentAbout from './components/agent-about.vue'
import FooterOperation from './components/footer-operation.vue'
import HistoryMenuSidebar from './components/history-menu-sidebar.vue'
import MessageList from './components/message-list.vue'
import type { AgentApplicationRecordItem, MessageItemInterface } from './types'
import { fetchCreateSessionId, fetchMessageRecordList } from '@/apis/home-agent'
import type { ValueOf } from 'type-fest'
import { useElementSize } from '@vueuse/core'
import { debounce } from 'lodash-es'
import { nanoid } from 'nanoid'
const homeContainerRef = useTemplateRef<HTMLDivElement>('homeContainerRef')
const messageListRef = useTemplateRef<InstanceType<typeof MessageList>>('messageListRef')
const historyMenuSidebarRef = useTemplateRef<InstanceType<typeof HistoryMenuSidebar>>('historyMenuSidebarRef')
const { width: homeContainerWidth } = useElementSize(homeContainerRef)
const isShowHistoryMenu = ref(true)
const currentAgentApplication = ref<AgentApplicationRecordItem>({
agentId: '',
agentTitle: '',
agentAvatar: '',
agentDesc: '',
creator: '',
publishedTime: '',
})
const currentSessionId = ref('')
const questionContent = ref('')
const messageList = ref(new Map<string, MessageItemInterface>())
const isAgentResponding = ref(false)
const isShowMessageList = ref(false)
const isAgentLoading = ref(true)
// messageList.value.set('1', {
// role: 'user',
// agentId: 'b058f1baedd04af983ca00775368bb8c',
// content: '请推荐一些适合初学者的编程学习资源。',
// timestamp: 1726654820427,
// isAnswerLoading: true,
// })
// messageList.value.set('2', {
// role: 'assistant',
// agentId: 'b058f1baedd04af983ca00775368bb8c',
// content:
// '对于初学者的编程学习资源,可以从以下几个方面进行推荐:\n\n### 一、在线教程与网站\n\n1. **w3school**:这是一个非常全面的编程学习网站,提供了从基础到高级的教程,包括HTML、CSS、JavaScript、SQL等,适合初学者逐步深入学习。\n2. **慕课网**:慕课网上有许多免费课程,涵盖了前端、后端开发,移动开发等多个方面,初学者可以根据自己的兴趣选择相应的课程。\n3. **Coursera**:该网站提供世界名校的网络公开课程,其中也包括计算机编程的相关课程,初学者可以接触到国际一流的教学资源。\n\n### 二、书籍推荐\n\n1. **《Python编程快速上手》**:这本书是为零基础读者打造的Python入门书籍,内容系统且详细,每个知识点都深入浅出,非常适合初学者。\n2. **《C++ Primer Plus》**:这本书是C++语言学习的理想图书,通过大量短小精悍的程序详细阐述了C++的基本概念和技术,对初学者极为友好。\n\n### 三、实践项目与刷题网站\n\n1. **Stack Overflow**:这是一个程序设计领域的问答网站,初学者在遇到编程难题时可以在这里寻找解决方案,同时也可以学习到其他技术大牛的经验和技巧。\n2. **GitHub**:作为全球最大的开源代码托管仓库,GitHub上有无穷无尽的开源代码供初学者学习和参考,阅读源码是一个快速提升编程能力的好方法。\n\n### 四、社区与论坛\n\n1. **CSDN软件开发网**:这是国内知名的软件开发社区,提供了大量的编程资源和经验分享,初学者可以在这里交流学习心得,获取最新的技术动态。\n\n综上所述,初学者可以根据自己的学习需求和兴趣选择合适的编程学习资源。从在线教程、书籍阅读到实践项目和社区交流,多方面的学习将有助于初学者快速掌握编程技能并不断提升自己。',
// timestamp: 1726654851735,
// isAnswerLoading: false,
// })
const homeContainerWidthWatchDebounce = debounce((newWidth) => {
if (newWidth <= 1060) {
isShowHistoryMenu.value = false
} else if (newWidth >= 1300) {
isShowHistoryMenu.value = true
}
}, 300)
watch(homeContainerWidth, homeContainerWidthWatchDebounce)
watch(
() => messageList.value.size,
(newSize) => {
if (newSize > 0) {
setTimeout(() => {
isShowMessageList.value = true
}, 400)
} else if (newSize === 0 && isShowMessageList.value) {
setTimeout(() => {
isShowMessageList.value = false
}, 400)
}
},
)
;(function () {
createSessionId()
})()
onMounted(() => {
setTimeout(() => {
messageListScrollToBottom()
}, 700)
})
function createSessionId() {
fetchCreateSessionId<string>().then((res) => {
currentSessionId.value = res.data
isAgentLoading.value = false
})
}
function onCreateNewSession() {
if (messageList.value.size === 0) {
window.$message.warning('当前已是最新会话')
return
}
createSessionId()
messageList.value.clear()
}
function messageListScrollToBottom() {
if (messageListRef.value) {
messageListRef.value.scrollToBottom()
}
}
function onAddMessageItem(messageId: string, messageItem: MessageItemInterface) {
messageList.value.set(messageId, messageItem)
}
function onUpdateSpecifyMessageItem(messageId: string, newMessageItem: Partial<MessageItemInterface>) {
const currentMessageItemInfo = messageList.value.get(messageId)
if (currentMessageItemInfo) {
const updatePropertyLength = Object.keys(newMessageItem).length
if (updatePropertyLength > 4) {
messageList.value.set(messageId, Object.assign({}, currentMessageItemInfo, newMessageItem))
return
}
Object.entries<ValueOf<typeof newMessageItem>>(newMessageItem).forEach(([key, value]) => {
if (Object.prototype.hasOwnProperty.call(currentMessageItemInfo, key)) {
;(currentMessageItemInfo as any)[key as keyof MessageItemInterface] = value
}
})
}
}
function onDeleteMessageItem(messageId: string) {
messageList.value.delete(messageId)
}
function onHistoryRecordListUpdate() {
if (historyMenuSidebarRef.value) {
historyMenuSidebarRef.value.historyRecordListUpdate()
}
}
function onGetMessageRecordList(recordId: string) {
currentSessionId.value = recordId
const loadingCtl = window.$message.loading('切换中...')
fetchMessageRecordList<MessageItemInterface[]>(recordId)
.then((res) => {
if (res.data && Array.isArray(res.data)) {
const messageListDraft = res.data.map((recordItem) => {
return [nanoid(), { ...recordItem, isAnswerLoading: false }]
})
messageList.value = new Map(messageListDraft as any)
window.$message.success('历史记录应用成功')
setTimeout(() => {
messageListScrollToBottom()
}, 500)
}
})
.catch(() => {
window.$message.error('历史记录应用失败,请重试')
})
.finally(() => {
loadingCtl.destroy()
})
}
</script>
<template>
<div class="bg-px-home-bg-png h-full w-full bg-contain bg-center bg-no-repeat">
<div class="mx-auto w-[740px] px-[5px] pt-[71px]">
<AgentAbout />
<div ref="homeContainerRef" class="relative h-full min-h-[650px] w-full">
<div
class="bg-px-home-home_bg-png relative h-full w-full bg-contain bg-center bg-no-repeat pr-0 transition-[padding] duration-300 ease-in-out"
:class="{ '!pr-[273px]': isShowHistoryMenu }"
>
<div class="mx-auto flex h-full w-[750px] flex-col px-[5px] py-[40px]">
<AgentAbout v-model:question-content="questionContent" :message-list-length="messageList.size" />
<MessageList v-show="isShowMessageList" ref="messageListRef" :message-list="messageList" />
<FooterOperation
v-model:current-agent-application="currentAgentApplication"
v-model:is-agent-responding="isAgentResponding"
v-model:question-content="questionContent"
:current-session-id="currentSessionId"
@message-list-scroll-to-bottom="messageListScrollToBottom"
@add-message-item="onAddMessageItem"
@update-specify-message-item="onUpdateSpecifyMessageItem"
@delete-message-item="onDeleteMessageItem"
@create-new-session="onCreateNewSession"
@history-record-list-update="onHistoryRecordListUpdate"
/>
</div>
<HistoryMenuSidebar
ref="historyMenuSidebarRef"
v-model="isShowHistoryMenu"
@get-message-record-list="onGetMessageRecordList"
/>
</div>
<Transition name="mask" mode="out-in">
<div
v-show="isAgentLoading"
class="z-100 absolute inset-0 flex flex-col items-center justify-center bg-[rgba(0,0,0,0.4)]"
>
<n-spin :size="50" />
<div class="text-theme-color mt-[30px] text-[16px]">加载中...</div>
</div>
</Transition>
</div>
</template>
<style lang="scss" scoped>
.main-content-container {
background-color: skyblue;
.mask-enter-active,
.mask-leave-active {
transition-timing-function: ease-in-out;
transition-duration: 0.2s;
transition-property: opacity;
}
.mask-enter-from,
.mask-leave-to {
opacity: 0;
}
</style>
export interface AgentApplicationRecordItem {
agentId: string
agentTitle: string
agentAvatar: string
agentDesc: string
creator: string
publishedTime: string
}
export interface MessageItemInterface {
role: 'user' | 'assistant'
agentId: string
content: string
timestamp: number
isAnswerLoading: boolean
}
import { BASE_URLS } from '@/config/base-url'
import { useUserStore } from '@/store/modules/user'
import { fetchEventSource } from '@microsoft/fetch-event-source'
interface Options {
onmessage?: (message: string) => void
onend?: () => void
onclose?: () => void
onerror?: (err: Error) => void
}
export default function fetchEventStreamSource(
url: string,
payload: object = {},
options: Options = {
onmessage: (_message: string) => {},
onend: () => {},
onclose: () => {},
onerror: (_err: Error) => {},
},
) {
const ENV = import.meta.env.VITE_APP_ENV
const userStore = useUserStore()
const controller = new AbortController()
fetchEventSource(`${BASE_URLS[ENV || 'DEV']}/api/rest${url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-request-token': userStore.token,
},
body: JSON.stringify(payload),
signal: controller?.signal,
onmessage: (e: { data: string }) => {
if (e.data === '[DONE]') {
options.onend && options.onend()
return
}
try {
const data = JSON.parse(e.data)
if (data.code === 0 || data.code === '0') {
options.onmessage && options.onmessage(data.message)
} else {
options.onerror && options.onerror(new Error(data.message))
}
} catch (error) {
options.onerror && options.onerror(error as Error)
}
},
onclose: () => {
options.onclose && options.onclose()
},
onerror: (err) => {
options.onerror && options.onerror(err)
throw err
},
})
return controller
}
......@@ -297,7 +297,7 @@ function handleEmailCodeGain() {
</script>
<template>
<div class="bg-px-login-bg-png relative h-screen min-h-[750px] w-full min-w-[600px] bg-cover bg-center bg-no-repeat">
<div class="bg-px-login_bg-png relative h-screen min-h-[750px] w-full min-w-[600px] bg-cover bg-center bg-no-repeat">
<div
class="bg-px-logo-png z-100 absolute left-[60px] top-[25px] h-[29px] w-[119px] bg-contain bg-center bg-no-repeat"
></div>
......
......@@ -4,14 +4,44 @@ export default defineConfig({
rules: [
[
/^bg-svg-([\w-]+)$/,
([, fname]) => ({ 'background-image': `url(@/assets/svgs/${fname}.svg)`, 'background-size': 'cover' }),
([, dirFname]) => {
let url = '@/assets/images/'
const dirFnameArr = dirFname.split('-')
if (dirFnameArr.length > 1) {
const [dirStr, fname] = dirFnameArr
const dirPath = dirStr.split('_').join('/')
url += `${dirPath}/${fname.replace('_', '-')}`
} else {
url += `${dirFname.replace('_', '-')}`
}
return { 'background-image': `url(@/assets/svgs/${url}.svg)`, 'background-size': 'cover' }
},
],
[
/^bg-px-([\w-]+)-(png|jpg|gif)$/,
([, fname, suffix]) => ({
'background-image': `url(@/assets/images/${fname}.${suffix})`,
'background-size': 'cover',
}),
/^bg-px-([\w_-]+)-(png|jpg|gif)$/,
([, dirFname, suffix]) => {
let url = '@/assets/images/'
const dirFnameArr = dirFname.split('-')
if (dirFnameArr.length > 1) {
const [dirStr, fname] = dirFnameArr
const dirPath = dirStr.split('_').join('/')
url += `${dirPath}/${fname.replace('_', '-')}.${suffix}`
} else {
url += `${dirFname.replace('_', '-')}.${suffix}`
}
return {
'background-image': `url(${url})`,
'background-size': 'cover',
}
},
],
],
theme: {
......@@ -50,4 +80,7 @@ export default defineConfig({
},
},
},
shortcuts: {
'flex-center': 'flex items-center justify-center',
},
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment