From 628187f5c3348bda0d0518f90699a86525d19018 Mon Sep 17 00:00:00 2001 From: puppywang Date: Sat, 25 Feb 2023 17:13:19 +0800 Subject: [PATCH] feat: add proxy support and fix streaming mode (#122) --- service/package.json | 4 +- service/pnpm-lock.yaml | 82 ++++++++++++++++++++++++- service/src/chatgpt.ts | 57 +++++++++++++++-- service/src/index.ts | 24 +++++++- src/api/index.ts | 18 +++++- src/components/common/Setting/index.vue | 2 + src/views/chat/index.vue | 77 ++++++++++++++++++++++- 7 files changed, 251 insertions(+), 13 deletions(-) diff --git a/service/package.json b/service/package.json index 339a0a3788..fa99f9c777 100644 --- a/service/package.json +++ b/service/package.json @@ -28,7 +28,9 @@ "dotenv": "^16.0.3", "esno": "^0.16.3", "express": "^4.18.2", - "isomorphic-fetch": "^3.0.0" + "isomorphic-fetch": "^3.0.0", + "node-fetch": "^3.3.0", + "socks-proxy-agent": "^7.0.0" }, "devDependencies": { "@antfu/eslint-config": "^0.35.2", diff --git a/service/pnpm-lock.yaml b/service/pnpm-lock.yaml index e4800ef0ee..fffe85e4fb 100644 --- a/service/pnpm-lock.yaml +++ b/service/pnpm-lock.yaml @@ -10,7 +10,9 @@ specifiers: esno: ^0.16.3 express: ^4.18.2 isomorphic-fetch: ^3.0.0 + node-fetch: ^3.3.0 rimraf: ^4.1.2 + socks-proxy-agent: ^7.0.0 tsup: ^6.6.3 typescript: ^4.9.5 @@ -20,6 +22,8 @@ dependencies: esno: 0.16.3 express: 4.18.2 isomorphic-fetch: 3.0.0 + node-fetch: 3.3.0 + socks-proxy-agent: 7.0.0 devDependencies: '@antfu/eslint-config': 0.35.2_7kw3g6rralp5ps6mg3uyzz6azm @@ -641,6 +645,15 @@ packages: hasBin: true dev: true + /agent-base/6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: false + /ajv-formats/2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependenciesMeta: @@ -999,6 +1012,11 @@ packages: hasBin: true dev: true + /data-uri-to-buffer/4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + dev: false + /debounce-fn/5.1.2: resolution: {integrity: sha512-Sr4SdOZ4vw6eQDvPYNxHogvrxmCIld/VenC5JbNrFwMiwd7lY/Z18ZFfo+EWNG4DD9nFlAujWAo/wGuOPHmy5A==} engines: {node: '>=12'} @@ -1038,7 +1056,6 @@ packages: optional: true dependencies: ms: 2.1.2 - dev: true /deep-is/0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1748,6 +1765,14 @@ packages: reusify: 1.0.4 dev: true + /fetch-blob/3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.2.1 + dev: false + /file-entry-cache/6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -1819,6 +1844,13 @@ packages: is-callable: 1.2.7 dev: true + /formdata-polyfill/4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + dependencies: + fetch-blob: 3.2.0 + dev: false + /forwarded/0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2086,6 +2118,10 @@ packages: side-channel: 1.0.4 dev: true + /ip/2.0.0: + resolution: {integrity: sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==} + dev: false + /ipaddr.js/1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2509,7 +2545,6 @@ packages: /ms/2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true /ms/2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2535,6 +2570,11 @@ packages: engines: {node: '>= 0.6'} dev: false + /node-domexception/1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: false + /node-fetch/2.6.9: resolution: {integrity: sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==} engines: {node: 4.x || >=6.0.0} @@ -2547,6 +2587,15 @@ packages: whatwg-url: 5.0.0 dev: false + /node-fetch/3.3.0: + resolution: {integrity: sha512-BKwRP/O0UvoMKp7GNdwPlObhYGB5DQqwhEDQlNKuoqwVYSxkSZCSbHjnFFmUEtwSKRPU4kNK8PbDYYitwaE3QA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + dev: false + /normalize-package-data/2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} dependencies: @@ -3083,6 +3132,30 @@ packages: engines: {node: '>=8'} dev: true + /smart-buffer/4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + dev: false + + /socks-proxy-agent/7.0.0: + resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} + engines: {node: '>= 10'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4 + socks: 2.7.1 + transitivePeerDependencies: + - supports-color + dev: false + + /socks/2.7.1: + resolution: {integrity: sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==} + engines: {node: '>= 10.13.0', npm: '>= 3.0.0'} + dependencies: + ip: 2.0.0 + smart-buffer: 4.2.0 + dev: false + /source-map-support/0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} dependencies: @@ -3440,6 +3513,11 @@ packages: - supports-color dev: true + /web-streams-polyfill/3.2.1: + resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} + engines: {node: '>= 8'} + dev: false + /webidl-conversions/3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: false diff --git a/service/src/chatgpt.ts b/service/src/chatgpt.ts index 518e739adb..07afbeb403 100644 --- a/service/src/chatgpt.ts +++ b/service/src/chatgpt.ts @@ -2,6 +2,8 @@ import * as dotenv from 'dotenv' import 'isomorphic-fetch' import type { ChatGPTAPI, ChatMessage, SendMessageOptions } from 'chatgpt' import { ChatGPTUnofficialProxyAPI } from 'chatgpt' +import { SocksProxyAgent } from 'socks-proxy-agent' +import fetch from 'node-fetch' import { sendResponse } from './utils' dotenv.config() @@ -30,10 +32,25 @@ let api: ChatGPTAPI | ChatGPTUnofficialProxyAPI apiModel = 'ChatGPTAPI' } else { - let options = {} - - if (process.env.API_REVERSE_PROXY) - options = { apiReverseProxyUrl: process.env.API_REVERSE_PROXY } + const options = { + debug: true, + } + + if (process.env.SOCKS_PROXY_HOST && process.env.SOCKS_PROXY_PORT) { + const agent = new SocksProxyAgent({ + hostname: process.env.SOCKS_PROXY_HOST, + port: process.env.SOCKS_PROXY_PORT, + }) + globalThis.console.log(`Using socks proxy: ${process.env.SOCKS_PROXY_HOST}:${process.env.SOCKS_PROXY_PORT}`) + options.fetch = (url, options) => { + return fetch(url, { agent, ...options }) + } + } + + if (process.env.API_REVERSE_PROXY) { + options.apiReverseProxyUrl = process.env.API_REVERSE_PROXY + globalThis.console.log(`Using api reverse proxy: ${process.env.API_REVERSE_PROXY}`) + } api = new ChatGPTUnofficialProxyAPI({ accessToken: process.env.OPENAI_ACCESS_TOKEN, @@ -65,6 +82,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', @@ -72,10 +118,11 @@ async function chatConfig() { apiModel, reverseProxy: process.env.API_REVERSE_PROXY, timeoutMs, + socksProxy: (process.env.SOCKS_PROXY_HOST && process.env.SOCKS_PROXY_PORT) ? (`${process.env.SOCKS_PROXY_HOST}:${process.env.SOCKS_PROXY_PORT}`) : '-', }, }) } export type { ChatContext, ChatMessage } -export { chatReply, chatConfig } +export { chatReply, chatReplyProcess, chatConfig } diff --git a/service/src/index.ts b/service/src/index.ts index 01faf4abb3..5aae25d4e9 100644 --- a/service/src/index.ts +++ b/service/src/index.ts @@ -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,26 @@ 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 } + let firstChunk = true + await chatReplyProcess(prompt, options, (chat: ChatMessage) => { + res.write(firstChunk ? JSON.stringify(chat) : `\n${JSON.stringify(chat)}`) + firstChunk = false + }) + } + catch (error) { + res.write(JSON.stringify(error)) + } + finally { + res.end() + } +}) + router.post('/config', async (req, res) => { try { const response = await chatConfig() diff --git a/src/api/index.ts b/src/api/index.ts index 1d3811f40d..024a9c0729 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,4 @@ -import type { GenericAbortSignal } from 'axios' +import type { AxiosProgressEvent, GenericAbortSignal } from 'axios' import { post } from '@/utils/request' export function fetchChatAPI( @@ -18,3 +18,19 @@ export function fetchChatConfig() { url: '/config', }) } + +/** 实验性质的函数,用于处理聊天过程中的中间结果 */ +export function fetchChatAPIProcess( + params: { + prompt: string + options?: { conversationId?: string; parentMessageId?: string } + signal?: GenericAbortSignal + onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void }, +) { + return post({ + url: '/chat-process', + data: { prompt: params.prompt, options: params.options }, + signal: params.signal, + onDownloadProgress: params.onDownloadProgress, + }) +} diff --git a/src/components/common/Setting/index.vue b/src/components/common/Setting/index.vue index e694fc8a0c..3ae1394820 100644 --- a/src/components/common/Setting/index.vue +++ b/src/components/common/Setting/index.vue @@ -16,6 +16,7 @@ interface ConfigState { timeoutMs?: number reverseProxy?: string apiModel?: string + socksProxy?: string } const props = defineProps() @@ -69,6 +70,7 @@ watch(

API方式:{{ config?.apiModel ?? '-' }}

反向代理:{{ config?.reverseProxy ?? '-' }}

超时时间:{{ config?.timeoutMs ?? '-' }}

+

Socks代理:{{ config?.socksProxy ?? '-' }}

diff --git a/src/views/chat/index.vue b/src/views/chat/index.vue index 87340037a4..cfbd549141 100644 --- a/src/views/chat/index.vue +++ b/src/views/chat/index.vue @@ -8,7 +8,7 @@ import { useChat } from './hooks/useChat' import { HoverButton, SvgIcon } from '@/components/common' import { useBasicLayout } from '@/hooks/useBasicLayout' import { useChatStore } from '@/store' -import { fetchChatAPI } from '@/api' +import { fetchChatAPIProcess } from '@/api' let controller = new AbortController() @@ -82,6 +82,42 @@ async function onConversation() { scrollToBottom() try { + await fetchChatAPIProcess({ + 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 { + globalThis.console.log(`trunk = ${chunk}`) + const data = JSON.parse(chunk) + updateChat( + +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) { + // + } + }, + }) + /* const { data } = await fetchChatAPI(message, options, controller.signal) updateChat( +uuid, @@ -97,6 +133,7 @@ async function onConversation() { }, ) scrollToBottom() +*/ } catch (error: any) { let errorMessage = error?.message ?? 'Something went wrong, please try again later.' @@ -156,6 +193,41 @@ async function onRegenerate(index: number) { ) try { + await fetchChatAPIProcess({ + 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 { + globalThis.console.log(`trunk = ${chunk}`) + const data = JSON.parse(chunk) + updateChat( + +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) { + // + } + }, + }) + /* const { data } = await fetchChatAPI(message, options, controller.signal) updateChat( +uuid, @@ -170,9 +242,10 @@ async function onRegenerate(index: number) { requestOptions: { prompt: message, ...options }, }, ) +*/ } catch (error: any) { - let errorMessage = 'Something went wrong, please try again later.' + let errorMessage = error?.message ?? 'Something went wrong, please try again later.' if (error.message === 'canceled') errorMessage = 'Request canceled. Please try again.'