Compare commits

...

2 Commits

Author SHA1 Message Date
Redon
42e320fe35 chore: version 2.8.3 (#175)
* feat: 保留已存在的内容直到手动操作

* feat: 支持复制文本

* chore: version 2.8.3
2023-03-01 13:20:31 +08:00
Redon
94e23bb916 chore: version 2.8.2 (#159)
* fix: 修复普通文本代码渲染和深色模式下的问题[#139][#154]

* chore: version 2.8.2
2023-02-28 10:00:53 +08:00
17 changed files with 409 additions and 45 deletions

View File

@@ -29,6 +29,7 @@
"dockerhub", "dockerhub",
"esno", "esno",
"GPTAPI", "GPTAPI",
"hljs",
"iconify", "iconify",
"logprobs", "logprobs",
"nodata", "nodata",

View File

@@ -1,3 +1,22 @@
## v2.8.3
`2023-03-01`
### Feature
- 消息已输出内容不会因为中断而消失[#167]
- 添加复制消息按钮[#133]
### Other
- `README` 添加声明内容
## v2.8.2
`2023-02-28`
### Enhancement
- 代码主题调整为 `One Dark - light|dark` 适配深色模式
### BugFix
- 修复普通文本代码渲染和深色模式下的问题[#139][#154]
## v2.8.1 ## v2.8.1
`2023-02-27` `2023-02-27`

View File

@@ -1,6 +1,6 @@
# ChatGPT Web # ChatGPT Web
> 使用 `express` 和 `vue3` 搭建的支持 `ChatGPT` 双模型演示网页 > 声明:此项目只发布于 Github基于 MIT 协议,免费且作为开源学习使用。并且不会有任何形式的卖号、付费服务、讨论群、讨论组等行为。谨防受骗。
![cover](./docs/c1-2.8.0.png) ![cover](./docs/c1-2.8.0.png)
![cover2](./docs/c2-2.8.0.png) ![cover2](./docs/c2-2.8.0.png)
@@ -174,7 +174,7 @@ version: '3'
services: services:
app: app:
image: chenzhaoyu94/chatgpt-web # 总是使用latest,更新时重新pull该tag镜像即可 image: chenzhaoyu94/chatgpt-web # 总是使用latest,更新时重新pull该tag镜像即可
ports: ports:
- 3002:3002 - 3002:3002
environment: environment:

View File

@@ -1,6 +1,6 @@
{ {
"name": "chatgpt-web", "name": "chatgpt-web",
"version": "2.8.1", "version": "2.8.3",
"private": false, "private": false,
"description": "ChatGPT Web", "description": "ChatGPT Web",
"author": "ChenZhaoYu <chenzhaoyu1994@gmail.com>", "author": "ChenZhaoYu <chenzhaoyu1994@gmail.com>",

View File

@@ -8,4 +8,4 @@ OPENAI_ACCESS_TOKEN=
API_REVERSE_PROXY= API_REVERSE_PROXY=
# timeout # timeout
TIMEOUT_MS=60000 TIMEOUT_MS=100000

View File

@@ -0,0 +1,36 @@
import { h } from 'vue'
import { SvgIcon } from '@/components/common'
export const useIconRender = () => {
interface IconConfig {
icon?: string
color?: string
fontSize?: number
}
interface IconStyle {
color?: string
fontSize?: string
}
const iconRender = (config: IconConfig) => {
const { color, fontSize, icon } = config
const style: IconStyle = {}
if (color)
style.color = color
if (fontSize)
style.fontSize = `${fontSize}px`
if (!icon)
window.console.warn('iconRender: icon is required')
return () => h(SvgIcon, { icon, style })
}
return {
iconRender,
}
}

View File

@@ -1,5 +1,5 @@
import 'highlight.js/styles/xcode.css'
import '@/styles/lib/tailwind.css' import '@/styles/lib/tailwind.css'
import '@/styles/lib/highlight.less'
import '@/styles/lib/github-markdown.less' import '@/styles/lib/github-markdown.less'
import '@/styles/global.less' import '@/styles/global.less'

View File

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

View File

@@ -0,0 +1,203 @@
html.dark {
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em
}
code.hljs {
padding: 3px 5px
}
.hljs {
color: #abb2bf;
background: #282c34
}
.hljs-keyword,
.hljs-operator,
.hljs-pattern-match {
color: #f92672
}
.hljs-function,
.hljs-pattern-match .hljs-constructor {
color: #61aeee
}
.hljs-function .hljs-params {
color: #a6e22e
}
.hljs-function .hljs-params .hljs-typing {
color: #fd971f
}
.hljs-module-access .hljs-module {
color: #7e57c2
}
.hljs-constructor {
color: #e2b93d
}
.hljs-constructor .hljs-string {
color: #9ccc65
}
.hljs-comment,
.hljs-quote {
color: #b18eb1;
font-style: italic
}
.hljs-doctag,
.hljs-formula {
color: #c678dd
}
.hljs-deletion,
.hljs-name,
.hljs-section,
.hljs-selector-tag,
.hljs-subst {
color: #e06c75
}
.hljs-literal {
color: #56b6c2
}
.hljs-addition,
.hljs-attribute,
.hljs-meta .hljs-string,
.hljs-regexp,
.hljs-string {
color: #98c379
}
.hljs-built_in,
.hljs-class .hljs-title,
.hljs-title.class_ {
color: #e6c07b
}
.hljs-attr,
.hljs-number,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-pseudo,
.hljs-template-variable,
.hljs-type,
.hljs-variable {
color: #d19a66
}
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id,
.hljs-symbol,
.hljs-title {
color: #61aeee
}
.hljs-emphasis {
font-style: italic
}
.hljs-strong {
font-weight: 700
}
.hljs-link {
text-decoration: underline
}
}
html {
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em
}
code.hljs {
padding: 3px 5px
}
.hljs {
color: #383a42;
background: #fafafa
}
.hljs-comment,
.hljs-quote {
color: #a0a1a7;
font-style: italic
}
.hljs-doctag,
.hljs-formula,
.hljs-keyword {
color: #a626a4
}
.hljs-deletion,
.hljs-name,
.hljs-section,
.hljs-selector-tag,
.hljs-subst {
color: #e45649
}
.hljs-literal {
color: #0184bb
}
.hljs-addition,
.hljs-attribute,
.hljs-meta .hljs-string,
.hljs-regexp,
.hljs-string {
color: #50a14f
}
.hljs-attr,
.hljs-number,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-pseudo,
.hljs-template-variable,
.hljs-type,
.hljs-variable {
color: #986801
}
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id,
.hljs-symbol,
.hljs-title {
color: #4078f2
}
.hljs-built_in,
.hljs-class .hljs-title,
.hljs-title.class_ {
color: #c18401
}
.hljs-emphasis {
font-style: italic
}
.hljs-strong {
font-weight: 700
}
.hljs-link {
text-decoration: underline
}
}

View File

@@ -13,3 +13,15 @@ export function includeCode(text: string | null | undefined) {
const regexp = /^(?:\s{4}|\t).+/gm const regexp = /^(?:\s{4}|\t).+/gm
return !!(text?.includes(' = ') || text?.match(regexp)) return !!(text?.includes(' = ') || text?.match(regexp))
} }
// 复制文本
export function copyText(text: string) {
const input = document.createElement('input')
input.setAttribute('readonly', 'readonly')
input.setAttribute('value', text)
document.body.appendChild(input)
input.select()
if (document.execCommand('copy'))
document.execCommand('copy')
document.body.removeChild(input)
}

View File

@@ -24,11 +24,17 @@ renderer.html = (html) => {
renderer.code = (code, language) => { renderer.code = (code, language) => {
const validLang = !!(language && hljs.getLanguage(language)) const validLang = !!(language && hljs.getLanguage(language))
const highlighted = validLang ? hljs.highlight(language, code).value : code if (validLang)
return `<pre><code class="hljs ${language}">${highlighted}</code></pre>` return `<pre><code class="hljs ${language}">${hljs.highlight(language, code).value}</code></pre>`
return `<pre style="background: none">${hljs.highlightAuto(code).value}</pre>`
} }
marked.setOptions({ renderer }) marked.setOptions({
renderer,
highlight(code) {
return hljs.highlightAuto(code).value
},
})
const wrapClass = computed(() => { const wrapClass = computed(() => {
return [ return [

View File

@@ -1,7 +1,10 @@
<script setup lang='ts'> <script setup lang='ts'>
import { NDropdown, useMessage } from 'naive-ui'
import AvatarComponent from './Avatar.vue' import AvatarComponent from './Avatar.vue'
import TextComponent from './Text.vue' import TextComponent from './Text.vue'
import { SvgIcon } from '@/components/common' import { SvgIcon } from '@/components/common'
import { copyText } from '@/utils/format'
import { useIconRender } from '@/hooks/useIconRender'
interface Props { interface Props {
dateTime?: string dateTime?: string
@@ -16,14 +19,42 @@ interface Emit {
(ev: 'delete'): void (ev: 'delete'): void
} }
defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emit>() const emit = defineEmits<Emit>()
const ms = useMessage()
const { iconRender } = useIconRender()
const options = [
{
label: 'Copy',
key: 'copy',
icon: iconRender({ icon: 'ri:file-copy-2-line' }),
}, {
label: 'Delete',
key: 'delete',
icon: iconRender({ icon: 'ri:delete-bin-line' }),
},
]
function handleSelect(key: 'copy' | 'delete') {
if (key === 'copy')
handleCopy()
else
handleDelete()
}
function handleDelete() { function handleDelete() {
emit('delete') emit('delete')
} }
function handleCopy() {
copyText(props.text ?? '')
ms.success('Copied')
}
function handleRegenerate() { function handleRegenerate() {
emit('regenerate') emit('regenerate')
} }
@@ -59,12 +90,11 @@ function handleRegenerate() {
> >
<SvgIcon icon="ri:restart-line" /> <SvgIcon icon="ri:restart-line" />
</button> </button>
<button <NDropdown :options="options" @select="handleSelect">
class="mb-1 transition text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200" <button class="transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-200">
@click="handleDelete" <SvgIcon icon="ri:function-line" />
> </button>
<SvgIcon icon="ri:delete-bin-6-line" /> </NDropdown>
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
background-color: transparent; background-color: transparent;
font-size: 14px; font-size: 14px;
p{ p {
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -23,13 +23,16 @@
pre { pre {
background-color: #fff; background-color: #fff;
} }
}
.dark{ code.hljs{
.markdown-body{ padding: 0;
.highlight pre, }
pre { }
background-color: #18181c;
} html.dark {
.highlight pre,
pre {
background-color: #282c34;
} }
} }

View File

@@ -3,6 +3,10 @@ import { useChatStore } from '@/store'
export function useChat() { export function useChat() {
const chatStore = useChatStore() const chatStore = useChatStore()
const getChatByUuidAndIndex = (uuid: number, index: number) => {
return chatStore.getChatByUuidAndIndex(uuid, index)
}
const addChat = (uuid: number, chat: Chat.Chat) => { const addChat = (uuid: number, chat: Chat.Chat) => {
chatStore.addChatByUuid(uuid, chat) chatStore.addChatByUuid(uuid, chat)
} }
@@ -11,8 +15,14 @@ export function useChat() {
chatStore.updateChatByUuid(uuid, index, chat) chatStore.updateChatByUuid(uuid, index, chat)
} }
const updateChatSome = (uuid: number, index: number, chat: Partial<Chat.Chat>) => {
chatStore.updateChatSomeByUuid(uuid, index, chat)
}
return { return {
addChat, addChat,
updateChat, updateChat,
updateChatSome,
getChatByUuidAndIndex,
} }
} }

View File

@@ -19,7 +19,7 @@ const ms = useMessage()
const chatStore = useChatStore() const chatStore = useChatStore()
const { isMobile } = useBasicLayout() const { isMobile } = useBasicLayout()
const { addChat, updateChat } = useChat() const { addChat, updateChat, updateChatSome, getChatByUuidAndIndex } = useChat()
const { scrollRef, scrollToBottom } = useScroll() const { scrollRef, scrollToBottom } = useScroll()
const { uuid } = route.params as { uuid: string } const { uuid } = route.params as { uuid: string }
@@ -71,7 +71,7 @@ async function onConversation() {
+uuid, +uuid,
{ {
dateTime: new Date().toLocaleString(), dateTime: new Date().toLocaleString(),
text: 'Aha, Thinking...', text: '',
loading: true, loading: true,
inversion: false, inversion: false,
error: false, error: false,
@@ -118,10 +118,34 @@ async function onConversation() {
}) })
} }
catch (error: any) { catch (error: any) {
let errorMessage = error?.message ?? 'Something went wrong, please try again later.' const errorMessage = error?.message ?? 'Something went wrong, please try again later.'
if (error.message === 'canceled') if (error.message === 'canceled') {
errorMessage = 'Request canceled. Please try again.' 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( updateChat(
+uuid, +uuid,
@@ -165,7 +189,7 @@ async function onRegenerate(index: number) {
index, index,
{ {
dateTime: new Date().toLocaleString(), dateTime: new Date().toLocaleString(),
text: 'Aha, Let me think again...', text: '',
inversion: false, inversion: false,
error: false, error: false,
loading: true, loading: true,
@@ -210,10 +234,18 @@ async function onRegenerate(index: number) {
}) })
} }
catch (error: any) { catch (error: any) {
let errorMessage = error?.message ?? 'Something went wrong, please try again later.' if (error.message === 'canceled') {
updateChatSome(
+uuid,
index,
{
loading: false,
},
)
return
}
if (error.message === 'canceled') const errorMessage = error?.message ?? 'Something went wrong, please try again later.'
errorMessage = 'Request canceled. Please try again.'
updateChat( updateChat(
+uuid, +uuid,
@@ -343,8 +375,8 @@ onUnmounted(() => {
@regenerate="onRegenerate(index)" @regenerate="onRegenerate(index)"
@delete="handleDelete(index)" @delete="handleDelete(index)"
/> />
<div class="flex justify-center"> <div class="sticky bottom-0 left-0 flex justify-center">
<NButton v-if="loading" ghost @click="handleStop"> <NButton v-if="loading" type="warning" @click="handleStop">
<template #icon> <template #icon>
<SvgIcon icon="ri:stop-circle-line" /> <SvgIcon icon="ri:stop-circle-line" />
</template> </template>

View File

@@ -1,11 +1,14 @@
<script setup lang='ts'> <script setup lang='ts'>
import { computed, h, ref } from 'vue' import { computed, ref } from 'vue'
import { NDropdown } from 'naive-ui' import { NDropdown } from 'naive-ui'
import { HoverButton, Setting, SvgIcon, UserAvatar } from '@/components/common' import { HoverButton, Setting, SvgIcon, UserAvatar } from '@/components/common'
import { useAppStore } from '@/store' import { useAppStore } from '@/store'
import { useIconRender } from '@/hooks/useIconRender'
const appStore = useAppStore() const appStore = useAppStore()
const { iconRender } = useIconRender()
const show = ref(false) const show = ref(false)
const theme = computed(() => appStore.theme) const theme = computed(() => appStore.theme)
@@ -14,26 +17,20 @@ const options = [
{ {
label: 'Dark', label: 'Dark',
key: 'dark', key: 'dark',
icon: renderIcon('ri:moon-foggy-line'), icon: iconRender({ icon: 'ri:moon-foggy-line' }),
}, },
{ {
label: 'Light', label: 'Light',
key: 'light', key: 'light',
icon: renderIcon('ri:sun-foggy-line'), icon: iconRender({ icon: 'ri:sun-foggy-line' }),
}, },
{ {
label: 'Auto', label: 'Auto',
key: 'auto', key: 'auto',
icon: renderIcon('ri:contrast-line'), icon: iconRender({ icon: 'ri:contrast-line' }),
}, },
] ]
function renderIcon(icon: string) {
return () => {
return h(SvgIcon, { icon })
}
}
function handleThemeChange(key: 'light' | 'dark' | 'auto') { function handleThemeChange(key: 'light' | 'dark' | 'auto') {
appStore.setTheme(key) appStore.setTheme(key)
} }

View File

@@ -16,7 +16,6 @@
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
}, },
// @vueuse/core 不能通过 vue-tsc 检查,所以这里需要忽略,以后将移除
"types": ["vite/client", "node", "naive-ui/volar", "web-bluetooth"] "types": ["vite/client", "node", "naive-ui/volar", "web-bluetooth"]
}, },
"exclude": ["node_modules", "dist", "service"] "exclude": ["node_modules", "dist", "service"]