赞
踩
ChatAI很多,网上很多说的ChatGPT搭建及源码都是假的,无法使用的,自己研究了好几天,搞定了基于Vue3搭建Chat GPT前后端端源码及搭建教程,现在分享出来,有喜欢的朋友自行下载搭建,用的是openAPI官方接口和key。
搭建好的ChatGPT主要可以实现多聊天窗口,聊天记录导出下载,刷新能保持原来的内容。
先说下技术框架及主要步骤:
准备科学上网工具,国外发码虚拟号,申请好key。(注:需要使用KE 上网,地区选择韩国,日本,印度,新加坡,美国);
前端vue3聊天界面交互,后端express转发接口
nginx反向代理转发
客户端源码:
- <script setup lang='ts'>
- import { computed, onMounted, onUnmounted, ref } from 'vue'
- import { useRoute } from 'vue-router'
- import { storeToRefs } from 'pinia'
- import { NAutoComplete, NButton, NInput, useDialog, useMessage } from 'naive-ui'
- import html2canvas from 'html2canvas'
- import { Message } from './components'
- import { useScroll } from './hooks/useScroll'
- import { useChat } from './hooks/useChat'
- import { useCopyCode } from './hooks/useCopyCode'
- import { useUsingContext } from './hooks/useUsingContext'
- import HeaderComponent from './components/Header/index.vue'
- import { HoverButton, SvgIcon } from '@/components/common'
- import { useBasicLayout } from '@/hooks/useBasicLayout'
- import { useChatStore, usePromptStore } from '@/store'
- import { fetchChatAPIProcess } from '@/api'
- import { t } from '@/locales'
-
- let controller = new AbortController()
-
- const openLongReply = import.meta.env.VITE_GLOB_OPEN_LONG_REPLY === 'true'
-
- const route = useRoute()
- const dialog = useDialog()
- const ms = useMessage()
-
- const chatStore = useChatStore()
-
- useCopyCode()
-
- const { isMobile } = useBasicLayout()
- const { addChat, updateChat, updateChatSome, getChatByUuidAndIndex } = useChat()
- const { scrollRef, scrollToBottom } = useScroll()
- const { usingContext, toggleUsingContext } = useUsingContext()
-
- const { uuid } = route.params as { uuid: string }
-
- const dataSources = computed(() => chatStore.getChatByUuid(+uuid))
- const conversationList = computed(() => dataSources.value.filter(item => (!item.inversion && !item.error)))
-
- const prompt = ref<string>('')
- const loading = ref<boolean>(false)
-
- // 添加PromptStore
- const promptStore = usePromptStore()
- // 使用storeToRefs,保证store修改后,联想部分能够重新渲染
- const { promptList: promptTemplate } = storeToRefs<any>(promptStore)
-
- function handleSubmit() {
- onConversation()
- }
-
- async function onConversation() {
- let message = prompt.value
-
- if (loading.value)
- return
-
- if (!message || message.trim() === '')
- return
-
- controller = new AbortController()
-
- addChat(
- +uuid,
- {
- dateTime: new Date().toLocaleString(),
- text: message,
- inversion: true,
- error: false,
- conversationOptions: null,
- requestOptions: { prompt: message, options: null },
- },
- )
- scrollToBottom()
-
- loading.value = true
- prompt.value = ''
-
- let options: Chat.ConversationRequest = {}
- const lastContext = conversationList.value[conversationList.value.length - 1]?.conversationOptions
-
- if (lastContext && usingContext.value)
- options = { ...lastContext }
-
- addChat(
- +uuid,
- {
- dateTime: new Date().toLocaleString(),
- text: '',
- loading: true,
- inversion: false,
- error: false,
- conversationOptions: null,
- requestOptions: { prompt: message, options: { ...options } },
- },
- )
- scrollToBottom()
-
- try {
- let lastText = ''
- const fetchChatAPIOnce = async () => {
- await fetchChatAPIProcess<Chat.ConversationResponse>({
- prompt: message,
- options,
- signal: controller.signal,
- onDownloadProgress: ({ event }) => {
- const xhr = event.target
- const { responseText } = xhr
- // Always process the final line
- const lastIndex = responseText.lastIndexOf('\n')
- let chunk = responseText
- if (lastIndex !== -1)
- chunk = responseText.substring(lastIndex)
- try {
- const data = JSON.parse(chunk)
- updateChat(
- +uuid,
- dataSources.value.length - 1,
- {
- dateTime: new Date().toLocaleString(),
- text: lastText + data.text ?? '',
- inversion: false,
- error: false,
- loading: false,
- conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
- requestOptions: { prompt: message, options: { ...options } },
- },
- )
-
- if (openLongReply && data.detail.choices[0].finish_reason === 'length') {
- options.parentMessageId = data.id
- lastText = data.text
- message = ''
- return fetchChatAPIOnce()
- }
-
- scrollToBottom()
- }
- catch (error) {
- //
- }
- },
- })
- }
-
- await fetchChatAPIOnce()
- }
- catch (error: any) {
- const errorMessage = error?.message ?? t('common.wrong')
-
- if (error.message === 'canceled') {
- updateChatSome(
- +uuid,
- dataSources.value.length - 1,
- {
- loading: false,
- },
- )
- scrollToBottom()
- return
- }
-
- const currentChat = getChatByUuidAndIndex(+uuid, dataSources.value.length - 1)
-
- if (currentChat?.text && currentChat.text !== '') {
- updateChatSome(
- +uuid,
- dataSources.value.length - 1,
- {
- text: `${currentChat.text}\n[${errorMessage}]`,
- error: false,
- loading: false,
- },
- )
- return
- }
-
- updateChat(
- +uuid,
- dataSources.value.length - 1,
- {
- dateTime: new Date().toLocaleString(),
- text: errorMessage,
- inversion: false,
- error: true,
- loading: false,
- conversationOptions: null,
- requestOptions: { prompt: message, options: { ...options } },
- },
- )
- scrollToBottom()
- }
- finally {
- loading.value = false
- }
- }
-
- async function onRegenerate(index: number) {
- if (loading.value)
- return
-
- controller = new AbortController()
-
- const { requestOptions } = dataSources.value[index]
-
- let message = requestOptions?.prompt ?? ''
-
- let options: Chat.ConversationRequest = {}
-
- if (requestOptions.options)
- options = { ...requestOptions.options }
-
- loading.value = true
-
- updateChat(
- +uuid,
- index,
- {
- dateTime: new Date().toLocaleString(),
- text: '',
- inversion: false,
- error: false,
- loading: true,
- conversationOptions: null,
- requestOptions: { prompt: message, ...options },
- },
- )
-
- try {
- let lastText = ''
- const fetchChatAPIOnce = async () => {
- await fetchChatAPIProcess<Chat.ConversationResponse>({
- prompt: message,
- options,
- signal: controller.signal,
- onDownloadProgress: ({ event }) => {
- const xhr = event.target
- const { responseText } = xhr
- // Always process the final line
- const lastIndex = responseText.lastIndexOf('\n')
- let chunk = responseText
- if (lastIndex !== -1)
- chunk = responseText.substring(lastIndex)
- try {
- const data = JSON.parse(chunk)
- updateChat(
- +uuid,
- index,
- {
- dateTime: new Date().toLocaleString(),
- text: lastText + data.text ?? '',
- inversion: false,
- error: false,
- loading: false,
- conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
- requestOptions: { prompt: message, ...options },
- },
- )
-
- if (openLongReply && data.detail.choices[0].finish_reason === 'length') {
- options.parentMessageId = data.id
- lastText = data.text
- message = ''
- return fetchChatAPIOnce()
- }
- }
- catch (error) {
- //
- }
- },
- })
- }
- await fetchChatAPIOnce()
- }
- catch (error: any) {
- if (error.message === 'canceled') {
- updateChatSome(
- +uuid,
- index,
- {
- loading: false,
- },
- )
- return
- }
-
- const errorMessage = error?.message ?? t('common.wrong')
-
- updateChat(
- +uuid,
- index,
- {
- dateTime: new Date().toLocaleString(),
- text: errorMessage,
- inversion: false,
- error: true,
- loading: false,
- conversationOptions: null,
- requestOptions: { prompt: message, ...options },
- },
- )
- }
- finally {
- loading.value = false
- }
- }
-
- function handleExport() {
- if (loading.value)
- return
-
- const d = dialog.warning({
- title: t('chat.exportImage'),
- content: t('chat.exportImageConfirm'),
- positiveText: t('common.yes'),
- negativeText: t('common.no'),
- onPositiveClick: async () => {
- try {
- d.loading = true
- const ele = document.getElementById('image-wrapper')
- const canvas = await html2canvas(ele as HTMLDivElement, {
- useCORS: true,
- })
- const imgUrl = canvas.toDataURL('image/png')
- const tempLink = document.createElement('a')
- tempLink.style.display = 'none'
- tempLink.href = imgUrl
- tempLink.setAttribute('download', 'chat-shot.png')
- if (typeof tempLink.download === 'undefined')
- tempLink.setAttribute('target', '_blank')
-
- document.body.appendChild(tempLink)
- tempLink.click()
- document.body.removeChild(tempLink)
- window.URL.revokeObjectURL(imgUrl)
- d.loading = false
- ms.success(t('chat.exportSuccess'))
- Promise.resolve()
- }
- catch (error: any) {
- ms.error(t('chat.exportFailed'))
- }
- finally {
- d.loading = false
- }
- },
- })
- }
-
- function handleDelete(index: number) {
- if (loading.value)
- return
-
- dialog.warning({
- title: t('chat.deleteMessage'),
- content: t('chat.deleteMessageConfirm'),
- positiveText: t('common.yes'),
- negativeText: t('common.no'),
- onPositiveClick: () => {
- chatStore.deleteChatByUuid(+uuid, index)
- },
- })
- }
-
- function handleClear() {
- if (loading.value)
- return
-
- dialog.warning({
- title: t('chat.clearChat'),
- content: t('chat.clearChatConfirm'),
- positiveText: t('common.yes'),
- negativeText: t('common.no'),
- onPositiveClick: () => {
- chatStore.clearChatByUuid(+uuid)
- },
- })
- }
-
- function handleEnter(event: KeyboardEvent) {
- if (!isMobile.value) {
- if (event.key === 'Enter' && !event.shiftKey) {
- event.preventDefault()
- handleSubmit()
- }
- }
- else {
- if (event.key === 'Enter' && event.ctrlKey) {
- event.preventDefault()
- handleSubmit()
- }
- }
- }
-
- function handleStop() {
- if (loading.value) {
- controller.abort()
- loading.value = false
- }
- }
-
- // 可优化部分
- // 搜索选项计算,这里使用value作为索引项,所以当出现重复value时渲染异常(多项同时出现选中效果)
- // 理想状态下其实应该是key作为索引项,但官方的renderOption会出现问题,所以就需要value反renderLabel实现
- const searchOptions = computed(() => {
- if (prompt.value.startsWith('/')) {
- return promptTemplate.value.filter((item: { key: string }) => item.key.toLowerCase().includes(prompt.value.substring(1).toLowerCase())).map((obj: { value: any }) => {
- return {
- label: obj.value,
- value: obj.value,
- }
- })
- }
- else {
- return []
- }
- })
-
- // value反渲染key
- const renderOption = (option: { label: string }) => {
- for (const i of promptTemplate.value) {
- if (i.value === option.label)
- return [i.key]
- }
- return []
- }
-
- const placeholder = computed(() => {
- if (isMobile.value)
- return t('chat.placeholderMobile')
- return t('chat.placeholder')
- })
-
- const buttonDisabled = computed(() => {
- return loading.value || !prompt.value || prompt.value.trim() === ''
- })
-
- const footerClass = computed(() => {
- let classes = ['p-4']
- if (isMobile.value)
- classes = ['sticky', 'left-0', 'bottom-0', 'right-0', 'p-2', 'pr-3', 'overflow-hidden']
- return classes
- })
-
- onMounted(() => {
- scrollToBottom()
- })
-
- onUnmounted(() => {
- if (loading.value)
- controller.abort()
- })
- </script>
-
- <template>
- <div class="flex flex-col w-full h-full">
- <HeaderComponent
- v-if="isMobile"
- :using-context="usingContext"
- @export="handleExport"
- @toggle-using-context="toggleUsingContext"
- />
- <main class="flex-1 overflow-hidden">
- <div
- id="scrollRef"
- ref="scrollRef"
- class="h-full overflow-hidden overflow-y-auto"
- >
- <div
- id="image-wrapper"
- class="w-full max-w-screen-xl m-auto dark:bg-[#101014]"
- :class="[isMobile ? 'p-2' : 'p-4']"
- >
- <template v-if="!dataSources.length">
- <div class="flex items-center justify-center mt-4 text-center text-neutral-300">
- <SvgIcon icon="ri:bubble-chart-fill" class="mr-2 text-3xl" />
- <span>Aha~</span>
- </div>
- </template>
- <template v-else>
- <div>
- <Message
- v-for="(item, index) of dataSources"
- :key="index"
- :date-time="item.dateTime"
- :text="item.text"
- :inversion="item.inversion"
- :error="item.error"
- :loading="item.loading"
- @regenerate="onRegenerate(index)"
- @delete="handleDelete(index)"
- />
- <div class="sticky bottom-0 left-0 flex justify-center">
- <NButton v-if="loading" type="warning" @click="handleStop">
- <template #icon>
- <SvgIcon icon="ri:stop-circle-line" />
- </template>
- 停止回复
- </NButton>
- </div>
- </div>
- </template>
- </div>
- </div>
- </main>
- <footer :class="footerClass">
- <div class="w-full max-w-screen-xl m-auto">
- <div class="flex items-center justify-between space-x-2">
- <HoverButton @click="handleClear">
- <span class="text-xl text-[#4f555e] dark:text-white">
- <SvgIcon icon="ri:delete-bin-line" />
- </span>
- </HoverButton>
- <HoverButton v-if="!isMobile" @click="handleExport">
- <span class="text-xl text-[#4f555e] dark:text-white">
- <SvgIcon icon="ri:download-2-line" />
- </span>
- </HoverButton>
- <HoverButton v-if="!isMobile" @click="toggleUsingContext">
- <span class="text-xl" :class="{ 'text-[#4b9e5f]': usingContext, 'text-[#a8071a]': !usingContext }">
- <SvgIcon icon="ri:chat-history-line" />
- </span>
- </HoverButton>
- <NAutoComplete v-model:value="prompt" :options="searchOptions" :render-label="renderOption">
- <template #default="{ handleInput, handleBlur, handleFocus }">
- <NInput
- v-model:value="prompt"
- type="textarea"
- :placeholder="placeholder"
- :autosize="{ minRows: 1, maxRows: isMobile ? 4 : 8 }"
- @input="handleInput"
- @focus="handleFocus"
- @blur="handleBlur"
- @keypress="handleEnter"
- />
- </template>
- </NAutoComplete>
- <NButton type="primary" :disabled="buttonDisabled" @click="handleSubmit">
- <template #icon>
- <span class="dark:text-black">
- <SvgIcon icon="ri:send-plane-fill" />
- </span>
- </template>
- </NButton>
- </div>
- </div>
- </footer>
- </div>
- </template>
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。