Compare commits

...

3 Commits

Author SHA1 Message Date
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
ChenZhaoYu
10058f151c chore: version 2.7.0 2023-02-22 23:08:00 +08:00
Redon
09359c3c46 feat: 流式输出内容 (#93)
* feat: 流式输出内容

* fix: 修复异常状态

* feat: markdown 链接颜色
2023-02-22 23:03:20 +08:00
14 changed files with 205 additions and 30 deletions

View File

@@ -1 +0,0 @@
VITE_GLOB_HTTP_PROXY=Y

View File

@@ -1,3 +1,27 @@
## v2.7.1
`2023-02-23`
因为消息流在 `accessToken` 中存在解析失败和消息不完整等一系列的问题,调整回正常消息形式
### Feature
- 现在可以中断请求过长没有答复的消息
- 现在可以删除单条消息
- 设置中显示当前版本信息
### BugFix
- 回退 `2.7.0` 的消息不稳定的问题
## v2.7.0
`2023-02-23`
### Feature
- 使用消息流返回信息,反应更迅速
### Enhancement
- 样式的一点小改动
## v2.6.2
`2023-02-22`

View File

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

View File

@@ -15,6 +15,7 @@
},
"scripts": {
"start": "esno ./src/index.ts",
"dev": "esno watch ./src/index.ts",
"prod": "esno ./build/index.js",
"build": "pnpm clean && tsup",
"clean": "rimraf build",

View File

@@ -1,6 +1,6 @@
import * as dotenv from 'dotenv'
import 'isomorphic-fetch'
import type { ChatGPTAPI, SendMessageOptions } from 'chatgpt'
import type { ChatGPTAPI, ChatMessage, SendMessageOptions } from 'chatgpt'
import { ChatGPTUnofficialProxyAPI } from 'chatgpt'
import { sendResponse } from './utils'
@@ -8,7 +8,7 @@ dotenv.config()
let apiModel: 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI' | undefined
export interface ChatContext {
interface ChatContext {
conversationId?: string
parentMessageId?: string
}
@@ -65,6 +65,35 @@ 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() {
return sendResponse({
type: 'Success',
@@ -76,4 +105,6 @@ async function chatConfig() {
})
}
export { chatReply, chatConfig }
export type { ChatContext, ChatMessage }
export { chatReply, chatReplyProcess, chatConfig }

View File

@@ -1,6 +1,6 @@
import express from 'express'
import type { ChatContext } from './chatgpt'
import { chatConfig, chatReply } from './chatgpt'
import type { ChatContext, ChatMessage } from './chatgpt'
import { chatConfig, chatReply, chatReplyProcess } from './chatgpt'
const app = express()
const router = express.Router()
@@ -26,6 +26,24 @@ 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) => {
try {
const response = await chatConfig()

View File

@@ -1,4 +1,4 @@
import type { GenericAbortSignal } from 'axios'
import type { AxiosProgressEvent, GenericAbortSignal } from 'axios'
import { post } from '@/utils/request'
export function fetchChatAPI<T = any>(
@@ -13,6 +13,22 @@ 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>() {
return post<T>({
url: '/config',

View File

@@ -1,6 +1,7 @@
<script setup lang='ts'>
import { computed, ref, watch } from 'vue'
import { NCard, NModal } from 'naive-ui'
import pkg from '../../../../package.json'
import { fetchChatConfig } from '@/api'
interface Props {
@@ -55,9 +56,16 @@ watch(
<NModal v-model:show="show" style="width: 80%; max-width: 460px;">
<NCard>
<div class="space-y-4">
<h1 class="text-xl font-bold">
当前后台设置
</h1>
<h2 class="text-xl font-bold text-center">
Version - {{ pkg.version }}
</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>反向代理{{ config?.reverseProxy ?? '-' }}</p>
<p>超时时间{{ config?.timeoutMs ?? '-' }}</p>

View File

@@ -2,6 +2,8 @@ import type { App, Directive } from 'vue'
import hljs from 'highlight.js'
import includeCode from '@/utils/functions/includeCode'
hljs.configure({ ignoreUnescapedHTML: true })
function highlightCode(el: HTMLElement) {
if (includeCode(el.textContent))
hljs.highlightBlock(el)

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) {
if (!uuid || uuid === 0) {
if (this.chat.length) {

View File

@@ -1,4 +1,4 @@
import type { AxiosResponse, GenericAbortSignal } from 'axios'
import type { AxiosProgressEvent, AxiosResponse, GenericAbortSignal } from 'axios'
import request from './axios'
export interface HttpOption {
@@ -6,6 +6,7 @@ export interface HttpOption {
data?: any
method?: string
headers?: any
onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
signal?: GenericAbortSignal
beforeRequest?: () => void
afterRequest?: () => void
@@ -17,9 +18,11 @@ export interface Response<T = any> {
status: string
}
function http<T = any>({ url, data, method, headers, signal, beforeRequest, afterRequest }: HttpOption) {
function http<T = any>(
{ url, data, method, headers, onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption,
) {
const successHandler = (res: AxiosResponse<Response<T>>) => {
if (res.data.status === 'Success')
if (res.data.status === 'Success' || typeof res.data === 'string')
return res.data
return Promise.reject(res.data)
@@ -37,17 +40,18 @@ function http<T = any>({ url, data, method, headers, signal, beforeRequest, afte
const params = Object.assign(typeof data === 'function' ? data() : data ?? {}, {})
return method === 'GET'
? request.get(url, { params, signal }).then(successHandler, failHandler)
: request.post(url, params, { headers, signal }).then(successHandler, failHandler)
? request.get(url, { params, signal, onDownloadProgress }).then(successHandler, failHandler)
: request.post(url, params, { headers, signal, onDownloadProgress }).then(successHandler, failHandler)
}
export function get<T = any>(
{ url, data, method = 'GET', signal, beforeRequest, afterRequest }: HttpOption,
{ url, data, method = 'GET', onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption,
): Promise<Response<T>> {
return http<T>({
url,
method,
data,
onDownloadProgress,
signal,
beforeRequest,
afterRequest,
@@ -55,13 +59,14 @@ export function get<T = any>(
}
export function post<T = any>(
{ url, data, method = 'POST', headers, signal, beforeRequest, afterRequest }: HttpOption,
{ url, data, method = 'POST', headers, onDownloadProgress, signal, beforeRequest, afterRequest }: HttpOption,
): Promise<Response<T>> {
return http<T>({
url,
method,
data,
headers,
onDownloadProgress,
signal,
beforeRequest,
afterRequest,

View File

@@ -15,7 +15,7 @@ const props = defineProps<Props>()
const wrapClass = computed(() => {
return [
'text-wrap',
'p-2',
'p-3',
'min-w-[20px]',
'rounded-md',
props.inversion ? 'bg-[#d2f9d1]' : 'bg-[#f4f6f8]',
@@ -36,7 +36,7 @@ const text = computed(() => {
<template>
<div :class="wrapClass">
<template v-if="loading">
<span class="w-[3px] h-[20px] block animate-blink" />
<span class="w-[5px] h-[20px] block animate-blink" />
</template>
<template v-else>
<code v-if="includeCode(text)" v-highlight class="leading-relaxed" v-text="text" />
@@ -51,6 +51,9 @@ const text = computed(() => {
max-width: 100%;
vertical-align: middle;
}
a {
color: #2d5cf6
}
}
.hljs {

View File

@@ -13,12 +13,18 @@ interface Props {
interface Emit {
(ev: 'regenerate'): void
(ev: 'copy'): void
(ev: 'delete'): void
}
defineProps<Props>()
const emit = defineEmits<Emit>()
function handleDelete() {
emit('delete')
}
function handleRegenerate() {
emit('regenerate')
}
@@ -36,15 +42,28 @@ function handleRegenerate() {
<span class="text-xs text-[#b4bbc4]">
{{ dateTime }}
</span>
<div class="flex items-end mt-2">
<Text :inversion="inversion" :error="error" :text="text" :loading="loading" />
<button
v-if="!inversion && !loading"
class="mb-2 ml-2 transition text-neutral-400 hover:text-neutral-800"
@click="handleRegenerate"
>
<SvgIcon icon="ri:restart-line" />
</button>
<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"
/>
<div class="flex flex-col">
<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>

View File

@@ -1,7 +1,7 @@
<script setup lang='ts'>
import { computed, onMounted, onUnmounted, ref } from 'vue'
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 { useScroll } from './hooks/useScroll'
import { useChat } from './hooks/useChat'
@@ -14,6 +14,7 @@ let controller = new AbortController()
const route = useRoute()
const dialog = useDialog()
const ms = useMessage()
const chatStore = useChatStore()
@@ -195,6 +196,22 @@ async function onRegenerate(index: number) {
}
}
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() {
if (loading.value)
return
@@ -217,6 +234,13 @@ function handleEnter(event: KeyboardEvent) {
}
}
function handleStop() {
if (loading.value) {
controller.abort()
loading.value = false
}
}
const buttonDisabled = computed(() => {
return loading.value || !prompt.value || prompt.value.trim() === ''
})
@@ -266,7 +290,16 @@ onUnmounted(() => {
:error="item.error"
:loading="item.loading"
@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>
</template>
</div>