Compare commits

...

2 Commits

Author SHA1 Message Date
Redon
b6fd9ae766 feat: v2.7.2 消息样式美化和优化代码 (#111)
* perf: 优化代码

* feat: 美化消息,支持 markdown 全语法

* chore: version 2.7.2
2023-02-24 15:03:49 +08:00
Redon
1e2f893ef6 chore: version 2.7.1 (#99)
* feat: 调整流输出为实验性质

* feat: 取消回答按钮

* feat: 更新版本查看

* feat: 单消息复制和删除功能

* feat: 消除警告

* feat: 优化删除功能

* chore: version 2.7.1
2023-02-23 12:44:28 +08:00
17 changed files with 197 additions and 203 deletions

View File

@@ -1,3 +1,24 @@
## v2.7.2
`2023-02-24`
### Enhancement
- 消息使用 [github-markdown-css](https://www.npmjs.com/package/github-markdown-css) 进行美化,现在支持全语法
- 移除测试无用函数
## v2.7.1
`2023-02-23`
因为消息流在 `accessToken` 中存在解析失败和消息不完整等一系列的问题,调整回正常消息形式
### Feature
- 现在可以中断请求过长没有答复的消息
- 现在可以删除单条消息
- 设置中显示当前版本信息
### BugFix
- 回退 `2.7.0` 的消息不稳定的问题
## v2.7.0 ## v2.7.0
`2023-02-23` `2023-02-23`

View File

@@ -6,9 +6,9 @@ export function createViteProxy(isOpenProxy: boolean, viteEnv: ImportMetaEnv) {
const proxy: Record<string, string | ProxyOptions> = { const proxy: Record<string, string | ProxyOptions> = {
'/api': { '/api': {
target: viteEnv.VITE_GLOB_API_URL, target: viteEnv.VITE_APP_API_BASE_URL,
changeOrigin: true, changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''), rewrite: path => path.replace('/api/', '/'),
}, },
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "chatgpt-web", "name": "chatgpt-web",
"version": "2.7.0", "version": "2.7.2",
"private": false, "private": false,
"description": "ChatGPT Web", "description": "ChatGPT Web",
"author": "ChenZhaoYu <chenzhaoyu1994@gmail.com>", "author": "ChenZhaoYu <chenzhaoyu1994@gmail.com>",
@@ -24,6 +24,7 @@
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^9.13.0", "@vueuse/core": "^9.13.0",
"github-markdown-css": "^5.2.0",
"highlight.js": "^11.7.0", "highlight.js": "^11.7.0",
"marked": "^4.2.12", "marked": "^4.2.12",
"naive-ui": "^2.34.3", "naive-ui": "^2.34.3",

6
pnpm-lock.yaml generated
View File

@@ -15,6 +15,7 @@ specifiers:
axios: ^1.3.3 axios: ^1.3.3
crypto-js: ^4.1.1 crypto-js: ^4.1.1
eslint: ^8.34.0 eslint: ^8.34.0
github-markdown-css: ^5.2.0
highlight.js: ^11.7.0 highlight.js: ^11.7.0
husky: ^8.0.3 husky: ^8.0.3
less: ^4.1.3 less: ^4.1.3
@@ -34,6 +35,7 @@ specifiers:
dependencies: dependencies:
'@vueuse/core': 9.13.0_vue@3.2.47 '@vueuse/core': 9.13.0_vue@3.2.47
github-markdown-css: 5.2.0
highlight.js: 11.7.0 highlight.js: 11.7.0
marked: 4.2.12 marked: 4.2.12
naive-ui: 2.34.3_vue@3.2.47 naive-ui: 2.34.3_vue@3.2.47
@@ -2524,6 +2526,10 @@ packages:
through2: 4.0.2 through2: 4.0.2
dev: true dev: true
/github-markdown-css/5.2.0:
resolution: {integrity: sha512-hq5RaCInSUZ48bImOZpkppW2/MT44StRgsbsZ8YA4vJFwLKB/Vo3k7R2t+pUGqO+ThG0QDMi96TewV/B3vyItg==}
dev: false
/glob-parent/5.1.2: /glob-parent/5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}

View File

@@ -65,34 +65,6 @@ async function chatReply(
} }
} }
async function chatReplyProcess(
message: string,
lastContext?: { conversationId?: string; parentMessageId?: string },
process?: (chat: ChatMessage) => void,
) {
if (!message)
return sendResponse({ type: 'Fail', message: 'Message is empty' })
try {
let options: SendMessageOptions = { timeoutMs }
if (lastContext)
options = { ...lastContext }
const response = await api.sendMessage(message, {
...options,
onProgress: (partialResponse) => {
process?.(partialResponse)
},
})
return sendResponse({ type: 'Success', data: response })
}
catch (error: any) {
return sendResponse({ type: 'Fail', message: error.message })
}
}
async function chatConfig() { async function chatConfig() {
return sendResponse({ return sendResponse({
type: 'Success', type: 'Success',
@@ -106,4 +78,4 @@ async function chatConfig() {
export type { ChatContext, ChatMessage } export type { ChatContext, ChatMessage }
export { chatReply, chatReplyProcess, chatConfig } export { chatReply, chatConfig }

View File

@@ -1,6 +1,6 @@
import express from 'express' import express from 'express'
import type { ChatContext, ChatMessage } from './chatgpt' import type { ChatContext } from './chatgpt'
import { chatConfig, chatReply, chatReplyProcess } from './chatgpt' import { chatConfig, chatReply } from './chatgpt'
const app = express() const app = express()
const router = express.Router() const router = express.Router()
@@ -26,23 +26,6 @@ router.post('/chat', async (req, res) => {
} }
}) })
router.post('/chat-process', async (req, res) => {
res.setHeader('Content-type', 'application/octet-stream')
try {
const { prompt, options = {} } = req.body as { prompt: string; options?: ChatContext }
await chatReplyProcess(prompt, options, (chat: ChatMessage) => {
res.write(JSON.stringify(chat))
})
}
catch (error) {
res.write(JSON.stringify(error))
}
finally {
res.end()
}
})
router.post('/config', async (req, res) => { router.post('/config', async (req, res) => {
try { try {
const response = await chatConfig() const response = await chatConfig()

View File

@@ -1,4 +1,4 @@
import type { AxiosProgressEvent, GenericAbortSignal } from 'axios' import type { GenericAbortSignal } from 'axios'
import { post } from '@/utils/request' import { post } from '@/utils/request'
export function fetchChatAPI<T = any>( export function fetchChatAPI<T = any>(
@@ -13,21 +13,6 @@ export function fetchChatAPI<T = any>(
}) })
} }
export function fetchChatAPIProcess<T = any>(
params: {
prompt: string
options?: { conversationId?: string; parentMessageId?: string }
signal?: GenericAbortSignal
onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void },
) {
return post<T>({
url: '/chat-process',
data: { prompt: params.prompt, options: params.options },
signal: params.signal,
onDownloadProgress: params.onDownloadProgress,
})
}
export function fetchChatConfig<T = any>() { export function fetchChatConfig<T = any>() {
return post<T>({ return post<T>({
url: '/config', url: '/config',

View File

@@ -1,6 +1,7 @@
<script setup lang='ts'> <script setup lang='ts'>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { NCard, NModal } from 'naive-ui' import { NCard, NModal } from 'naive-ui'
import pkg from '../../../../package.json'
import { fetchChatConfig } from '@/api' import { fetchChatConfig } from '@/api'
interface Props { interface Props {
@@ -55,9 +56,16 @@ watch(
<NModal v-model:show="show" style="width: 80%; max-width: 460px;"> <NModal v-model:show="show" style="width: 80%; max-width: 460px;">
<NCard> <NCard>
<div class="space-y-4"> <div class="space-y-4">
<h1 class="text-xl font-bold"> <h2 class="text-xl font-bold text-center">
当前后台设置 Version - {{ pkg.version }}
</h1> </h2>
<hr>
<p>
此项目开源于
<a class="text-blue-600" href="https://github.com/Chanzhaoyu/chatgpt-web" target="_blank">Github</a>
免费并且协议为 MIT其他来源均为盗版使用时请注意如果你觉得此项目对你有帮助请帮我点个 Star谢谢
</p>
<hr>
<p>API方式{{ config?.apiModel ?? '-' }}</p> <p>API方式{{ config?.apiModel ?? '-' }}</p>
<p>反向代理{{ config?.reverseProxy ?? '-' }}</p> <p>反向代理{{ config?.reverseProxy ?? '-' }}</p>
<p>超时时间{{ config?.timeoutMs ?? '-' }}</p> <p>超时时间{{ config?.timeoutMs ?? '-' }}</p>

View File

@@ -1,21 +0,0 @@
import type { App, Directive } from 'vue'
import hljs from 'highlight.js'
import includeCode from '@/utils/functions/includeCode'
function highlightCode(el: HTMLElement) {
if (includeCode(el.textContent))
hljs.highlightBlock(el)
}
export default function setupHighlightDirective(app: App) {
const highLightDirective: Directive<HTMLElement> = {
mounted(el: HTMLElement) {
highlightCode(el)
},
updated(el: HTMLElement) {
highlightCode(el)
},
}
app.directive('highlight', highLightDirective)
}

View File

@@ -1,6 +1 @@
import type { App } from 'vue' export function setupDirectives() {}
import setupHighlightDirective from './highlight'
export function setupDirectives(app: App) {
setupHighlightDirective(app)
}

View File

@@ -1,6 +1,5 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import { setupDirectives } from './directives'
import { setupAssets } from '@/plugins' import { setupAssets } from '@/plugins'
import { setupStore } from '@/store' import { setupStore } from '@/store'
import { setupRouter } from '@/router' import { setupRouter } from '@/router'
@@ -11,8 +10,6 @@ async function bootstrap() {
setupStore(app) setupStore(app)
setupDirectives(app)
await setupRouter(app) await setupRouter(app)
app.mount('#app') app.mount('#app')

View File

@@ -1,4 +1,5 @@
import 'highlight.js/styles/xcode.css' import 'highlight.js/styles/xcode.css'
import 'github-markdown-css/github-markdown.css'
import '@/styles/global.css' import '@/styles/global.css'
/** Tailwind's Preflight Style Override */ /** Tailwind's Preflight Style Override */

View File

@@ -110,6 +110,22 @@ export const useChatStore = defineStore('chat-store', {
} }
}, },
deleteChatByUuid(uuid: number, index: number) {
if (!uuid || uuid === 0) {
if (this.chat.length) {
this.chat[0].data.splice(index, 1)
this.recordState()
}
return
}
const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
if (chatIndex !== -1) {
this.chat[chatIndex].data.splice(index, 1)
this.recordState()
}
},
clearChatByUuid(uuid: number) { clearChatByUuid(uuid: number) {
if (!uuid || uuid === 0) { if (!uuid || uuid === 0) {
if (this.chat.length) { if (this.chat.length) {

View File

@@ -1,7 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import { marked } from 'marked' import { marked } from 'marked'
import includeCode from '@/utils/functions/includeCode' import hljs from 'highlight.js'
const props = defineProps<Props>()
marked.setOptions({
renderer: new marked.Renderer(),
highlight(code) {
return hljs.highlightAuto(code).value
},
})
interface Props { interface Props {
inversion?: boolean inversion?: boolean
@@ -10,8 +19,6 @@ interface Props {
loading?: boolean loading?: boolean
} }
const props = defineProps<Props>()
const wrapClass = computed(() => { const wrapClass = computed(() => {
return [ return [
'text-wrap', 'text-wrap',
@@ -24,11 +31,8 @@ const wrapClass = computed(() => {
}) })
const text = computed(() => { const text = computed(() => {
if (props.text) { if (props.text)
if (!includeCode(props.text)) return marked(props.text)
return marked.parse(props.text)
return props.text
}
return '' return ''
}) })
</script> </script>
@@ -36,28 +40,16 @@ const text = computed(() => {
<template> <template>
<div :class="wrapClass"> <div :class="wrapClass">
<template v-if="loading"> <template v-if="loading">
<span class="w-[3px] h-[20px] block animate-blink" /> <span class="w-[5px] h-[20px] block animate-blink" />
</template> </template>
<template v-else> <template v-else>
<code v-if="includeCode(text)" v-highlight class="leading-relaxed" v-text="text" /> <div class="leading-relaxed break-all">
<div v-else class="leading-relaxed break-all" v-html="text" /> <div :class="[{ 'markdown-body': !inversion }]" v-html="text" />
</div>
</template> </template>
</div> </div>
</template> </template>
<style lang="less"> <style lang="less">
.text-wrap{ @import url(./style.less);
img{
max-width: 100%;
vertical-align: middle;
}
a {
color: #2d5cf6
}
}
.hljs {
background-color: #fff0 !important;
white-space: break-spaces;
}
</style> </style>

View File

@@ -1,6 +1,6 @@
<script setup lang='ts'> <script setup lang='ts'>
import Avatar from './Avatar.vue' import AvatarComponent from './Avatar.vue'
import Text from './Text.vue' import TextComponent from './Text.vue'
import { SvgIcon } from '@/components/common' import { SvgIcon } from '@/components/common'
interface Props { interface Props {
@@ -13,12 +13,18 @@ interface Props {
interface Emit { interface Emit {
(ev: 'regenerate'): void (ev: 'regenerate'): void
(ev: 'copy'): void
(ev: 'delete'): void
} }
defineProps<Props>() defineProps<Props>()
const emit = defineEmits<Emit>() const emit = defineEmits<Emit>()
function handleDelete() {
emit('delete')
}
function handleRegenerate() { function handleRegenerate() {
emit('regenerate') emit('regenerate')
} }
@@ -30,21 +36,34 @@ function handleRegenerate() {
class="flex items-center justify-center rounded-full overflow-hidden w-[32px] h-[32px]" class="flex items-center justify-center rounded-full overflow-hidden w-[32px] h-[32px]"
:class="[inversion ? 'ml-3' : 'mr-3']" :class="[inversion ? 'ml-3' : 'mr-3']"
> >
<Avatar :image="inversion" /> <AvatarComponent :image="inversion" />
</div> </div>
<div class="flex flex-col flex-1 text-sm" :class="[inversion ? 'items-end' : 'items-start']"> <div class="flex flex-col flex-1 text-sm" :class="[inversion ? 'items-end' : 'items-start']">
<span class="text-xs text-[#b4bbc4]"> <span class="text-xs text-[#b4bbc4]">
{{ dateTime }} {{ dateTime }}
</span> </span>
<div class="flex items-end mt-2"> <div class="flex items-end gap-2 mt-2" :class="[inversion ? 'flex-row-reverse' : 'flex-row']">
<Text :inversion="inversion" :error="error" :text="text" :loading="loading" /> <TextComponent
<button :inversion="inversion"
v-if="!inversion && !loading" :error="error"
class="mb-2 ml-2 transition text-neutral-400 hover:text-neutral-800" :text="text"
@click="handleRegenerate" :loading="loading"
> />
<SvgIcon icon="ri:restart-line" /> <div class="flex flex-col">
</button> <button
v-if="!inversion"
class="mb-2 transition text-neutral-400 hover:text-neutral-800"
@click="handleRegenerate"
>
<SvgIcon icon="ri:restart-line" />
</button>
<button
class="mb-1 transition text-neutral-400 hover:text-neutral-800"
@click="handleDelete"
>
<SvgIcon icon="ri:delete-bin-6-line" />
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,22 @@
.markdown-body {
background-color: transparent;
font-size: 14px;
ol {
list-style-type: decimal;
}
ul {
list-style-type: disc;
}
pre code,
pre tt {
line-height: 1.65;
}
.highlight pre,
pre {
background-color: #fff;
}
}

View File

@@ -1,19 +1,20 @@
<script setup lang='ts'> <script setup lang='ts'>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { NButton, NInput, useDialog } from 'naive-ui' import { NButton, NInput, useDialog, useMessage } from 'naive-ui'
import { Message } from './components' import { Message } from './components'
import { useScroll } from './hooks/useScroll' import { useScroll } from './hooks/useScroll'
import { useChat } from './hooks/useChat' import { useChat } from './hooks/useChat'
import { HoverButton, SvgIcon } from '@/components/common' import { HoverButton, SvgIcon } from '@/components/common'
import { useBasicLayout } from '@/hooks/useBasicLayout' import { useBasicLayout } from '@/hooks/useBasicLayout'
import { useChatStore } from '@/store' import { useChatStore } from '@/store'
import { fetchChatAPIProcess } from '@/api' import { fetchChatAPI } from '@/api'
let controller = new AbortController() let controller = new AbortController()
const route = useRoute() const route = useRoute()
const dialog = useDialog() const dialog = useDialog()
const ms = useMessage()
const chatStore = useChatStore() const chatStore = useChatStore()
@@ -80,39 +81,22 @@ async function onConversation() {
) )
scrollToBottom() scrollToBottom()
let offset = 0
try { try {
await fetchChatAPIProcess<Chat.ConversationResponse>({ const { data } = await fetchChatAPI<Chat.ConversationResponse>(message, options, controller.signal)
prompt: message, updateChat(
options, +uuid,
signal: controller.signal, dataSources.value.length - 1,
onDownloadProgress: ({ event }) => { {
const xhr = event.target dateTime: new Date().toLocaleString(),
const { responseText } = xhr text: data.text ?? '',
const chunk = responseText.substring(offset) inversion: false,
offset = responseText.length error: false,
try { loading: false,
const data = JSON.parse(chunk) conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
updateChat( requestOptions: { prompt: message, options: { ...options } },
+uuid,
dataSources.value.length - 1,
{
dateTime: new Date().toLocaleString(),
text: data.text ?? '',
inversion: false,
error: false,
loading: false,
conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
requestOptions: { prompt: message, options: { ...options } },
},
)
scrollToBottom()
}
catch (error) {
//
}
}, },
}) )
scrollToBottom()
} }
catch (error: any) { catch (error: any) {
let errorMessage = error?.message ?? 'Something went wrong, please try again later.' let errorMessage = error?.message ?? 'Something went wrong, please try again later.'
@@ -136,7 +120,6 @@ async function onConversation() {
scrollToBottom() scrollToBottom()
} }
finally { finally {
offset = 0
loading.value = false loading.value = false
} }
} }
@@ -172,41 +155,24 @@ async function onRegenerate(index: number) {
}, },
) )
let offset = 0
try { try {
await fetchChatAPIProcess<Chat.ConversationResponse>({ const { data } = await fetchChatAPI<Chat.ConversationResponse>(message, options, controller.signal)
prompt: message, updateChat(
options, +uuid,
signal: controller.signal, index,
onDownloadProgress: ({ event }) => { {
const xhr = event.target dateTime: new Date().toLocaleString(),
const { responseText } = xhr text: data.text ?? '',
const chunk = responseText.substring(offset) inversion: false,
offset = responseText.length error: false,
try { loading: false,
const data = JSON.parse(chunk) conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
updateChat( requestOptions: { prompt: message, ...options },
+uuid,
index,
{
dateTime: new Date().toLocaleString(),
text: data.text ?? '',
inversion: false,
error: false,
loading: false,
conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
requestOptions: { prompt: message, ...options },
},
)
}
catch (error) {
//
}
}, },
}) )
} }
catch (error: any) { catch (error: any) {
let errorMessage = error?.message ?? 'Something went wrong, please try again later.' let errorMessage = 'Something went wrong, please try again later.'
if (error.message === 'canceled') if (error.message === 'canceled')
errorMessage = 'Request canceled. Please try again.' errorMessage = 'Request canceled. Please try again.'
@@ -227,10 +193,25 @@ async function onRegenerate(index: number) {
} }
finally { finally {
loading.value = false loading.value = false
offset = 0
} }
} }
function handleDelete(index: number) {
if (loading.value)
return
dialog.warning({
title: 'Delete Message',
content: 'Are you sure to delete this message?',
positiveText: 'Yes',
negativeText: 'No',
onPositiveClick: () => {
chatStore.deleteChatByUuid(+uuid, index)
ms.success('Message deleted successfully.')
},
})
}
function handleClear() { function handleClear() {
if (loading.value) if (loading.value)
return return
@@ -253,6 +234,13 @@ function handleEnter(event: KeyboardEvent) {
} }
} }
function handleStop() {
if (loading.value) {
controller.abort()
loading.value = false
}
}
const buttonDisabled = computed(() => { const buttonDisabled = computed(() => {
return loading.value || !prompt.value || prompt.value.trim() === '' return loading.value || !prompt.value || prompt.value.trim() === ''
}) })
@@ -302,7 +290,16 @@ onUnmounted(() => {
:error="item.error" :error="item.error"
:loading="item.loading" :loading="item.loading"
@regenerate="onRegenerate(index)" @regenerate="onRegenerate(index)"
@delete="handleDelete(index)"
/> />
<div class="flex justify-center">
<NButton v-if="loading" ghost @click="handleStop">
<template #icon>
<SvgIcon icon="ri:stop-circle-line" />
</template>
Stop Responding
</NButton>
</div>
</div> </div>
</template> </template>
</div> </div>