Skip to content

Commit 9cbe4f3

Browse files
committed
improve websocket support for chatgpt web mode (#652)
1 parent f2bbc86 commit 9cbe4f3

File tree

2 files changed

+140
-136
lines changed

2 files changed

+140
-136
lines changed

src/services/apis/chatgpt-web.mjs

+138-136
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ export async function isNeedWebsocket(accessToken) {
9797
)
9898
}
9999

100+
export async function sendWebsocketConversation(accessToken, options) {
101+
const apiUrl = (await getUserConfig()).customChatGptWebApiUrl
102+
const response = await fetch(`${apiUrl}/backend-api/conversation`, options).then((r) => r.json())
103+
console.debug(`request: ws /conversation`, response)
104+
return { conversationId: response.conversation_id, wsRequestId: response.websocket_request_id }
105+
}
106+
100107
export async function stopWebsocketConversation(accessToken, conversationId, wsRequestId) {
101108
await request(accessToken, 'POST', '/stop_conversation', {
102109
conversation_id: conversationId,
@@ -111,11 +118,11 @@ let websocket
111118
/**
112119
* @type {Date}
113120
*/
114-
let expired_at
121+
let expires_at
115122
let wsCallbacks = []
116123

117124
export async function registerWebsocket(accessToken) {
118-
if (websocket && new Date() < expired_at - 300000) return
125+
if (websocket && new Date() < expires_at - 300000) return
119126

120127
const response = JSON.parse(
121128
(await request(accessToken, 'POST', '/register-websocket')).responseText,
@@ -124,11 +131,13 @@ export async function registerWebsocket(accessToken) {
124131
websocket = new WebSocket(response.wss_url)
125132
websocket.onclose = () => {
126133
websocket = null
134+
expires_at = null
135+
console.debug('global websocket closed')
127136
}
128137
websocket.onmessage = (event) => {
129138
wsCallbacks.forEach((cb) => cb(event))
130139
}
131-
expired_at = new Date(response.expired_at)
140+
expires_at = new Date(response.expires_at)
132141
}
133142
}
134143

@@ -139,15 +148,14 @@ export async function registerWebsocket(accessToken) {
139148
* @param {string} accessToken
140149
*/
141150
export async function generateAnswersWithChatgptWebApi(port, question, session, accessToken) {
142-
let ws
143151
const { controller, cleanController } = setAbortController(
144152
port,
145153
() => {
146-
if (ws) ws.close()
154+
if (session.wsRequestId)
155+
stopWebsocketConversation(accessToken, session.conversationId, session.wsRequestId)
147156
},
148157
() => {
149158
if (session.autoClean) deleteConversation(accessToken, session.conversationId)
150-
if (ws) ws.close()
151159
},
152160
)
153161

@@ -184,13 +192,9 @@ export async function generateAnswersWithChatgptWebApi(port, question, session,
184192
).value
185193
}
186194

187-
let answer = ''
188-
let generationPrefixAnswer = ''
189-
let generatedImageUrl = ''
190-
let wss_url = ''
191-
192195
const url = `${config.customChatGptWebApiUrl}${config.customChatGptWebApiPath}`
193196
session.messageId = uuidv4()
197+
session.wsRequestId = uuidv4()
194198
if (session.parentMessageId == null) {
195199
session.parentMessageId = uuidv4()
196200
}
@@ -232,141 +236,139 @@ export async function generateAnswersWithChatgptWebApi(port, question, session,
232236
parent_message_id: session.parentMessageId,
233237
timezone_offset_min: new Date().getTimezoneOffset(),
234238
history_and_training_disabled: config.disableWebModeHistory,
239+
websocket_request_id: session.wsRequestId,
235240
}),
236241
}
237-
await fetchSSE(url, {
238-
...options,
239-
onMessage(message) {
240-
function handleMessage(data) {
241-
if (data.error) {
242-
throw new Error(data.error)
243-
}
244242

245-
if (data.conversation_id) session.conversationId = data.conversation_id
246-
if (data.message?.id) session.parentMessageId = data.message.id
247-
248-
const respAns = data.message?.content?.parts?.[0]
249-
const contentType = data.message?.content?.content_type
250-
if (contentType === 'text' && respAns) {
251-
answer =
252-
generationPrefixAnswer +
253-
(generatedImageUrl && `\n\n![](${generatedImageUrl})\n\n`) +
254-
respAns
255-
} else if (contentType === 'code' && data.message?.status === 'in_progress') {
256-
const generationText = '\n\n' + t('Generating...')
257-
if (answer && !answer.endsWith(generationText)) generationPrefixAnswer = answer
258-
answer = generationPrefixAnswer + generationText
259-
} else if (
260-
contentType === 'multimodal_text' &&
261-
respAns?.content_type === 'image_asset_pointer'
262-
) {
263-
const imageAsset = respAns?.asset_pointer || ''
264-
if (imageAsset) {
265-
fetch(
266-
`${config.customChatGptWebApiUrl}/backend-api/files/${imageAsset.replace(
267-
'file-service://',
268-
'',
269-
)}/download`,
270-
{
271-
credentials: 'include',
272-
headers: {
273-
Authorization: `Bearer ${accessToken}`,
274-
...(cookie && { Cookie: cookie }),
275-
},
276-
},
277-
).then((r) => r.json().then((json) => (generatedImageUrl = json?.download_url)))
278-
}
279-
}
280-
281-
if (answer) {
282-
port.postMessage({ answer: answer, done: false, session: null })
283-
}
284-
}
285-
286-
function finishMessage() {
287-
pushRecord(session, question, answer)
288-
console.debug('conversation history', { content: session.conversationRecords })
289-
port.postMessage({ answer: answer, done: true, session: session })
290-
}
243+
let answer = ''
244+
let generationPrefixAnswer = ''
245+
let generatedImageUrl = ''
291246

292-
console.debug('sse message', message)
293-
if (message.trim() === '[DONE]') {
294-
if (!wss_url) {
295-
finishMessage()
296-
} else {
297-
ws = new WebSocket(wss_url)
298-
ws.onmessage = (event) => {
299-
let wsData
300-
try {
301-
wsData = JSON.parse(event.data)
302-
} catch (error) {
303-
console.debug('json error', error)
304-
return
305-
}
306-
if (wsData.type === 'http.response.body') {
307-
let body
308-
try {
309-
body = atob(wsData.body).replace(/^data:/, '')
310-
const data = JSON.parse(body)
311-
console.debug('ws message', data)
312-
if (wsData.conversation_id === session.conversationId) {
313-
handleMessage(data)
314-
}
315-
} catch (error) {
316-
if (body && body.trim() === '[DONE]') {
317-
console.debug('ws message', '[DONE]')
318-
if (wsData.conversation_id === session.conversationId) {
319-
finishMessage()
320-
ws.close()
321-
}
322-
} else {
323-
console.debug('json error', error)
324-
}
325-
}
326-
}
327-
}
328-
ws.onopen = () => {
329-
// fetch(url, options)
330-
}
331-
ws.onclose = () => {
332-
port.postMessage({ done: true })
333-
cleanController()
334-
}
335-
ws.onerror = (event) => {
336-
console.debug('ws error', event)
337-
port.postMessage({ error: event })
338-
cleanController()
339-
}
340-
}
341-
return
342-
}
343-
let data
247+
if (useWebsocket) {
248+
await registerWebsocket(accessToken)
249+
const wsCallback = async (event) => {
250+
let wsData
344251
try {
345-
data = JSON.parse(message)
252+
wsData = JSON.parse(event.data)
346253
} catch (error) {
347254
console.debug('json error', error)
348255
return
349256
}
350-
if (data.wss_url) wss_url = data.wss_url
351-
handleMessage(data)
352-
},
353-
async onStart() {
354-
// sendModerations(accessToken, question, session.conversationId, session.messageId)
355-
},
356-
async onEnd() {
357-
if (!wss_url) {
257+
if (wsData.type === 'http.response.body') {
258+
let body
259+
try {
260+
body = atob(wsData.body).replace(/^data:/, '')
261+
const data = JSON.parse(body)
262+
console.debug('ws message', data)
263+
if (wsData.conversation_id === session.conversationId) {
264+
handleMessage(data)
265+
}
266+
} catch (error) {
267+
if (body && body.trim() === '[DONE]') {
268+
console.debug('ws message', '[DONE]')
269+
if (wsData.conversation_id === session.conversationId) {
270+
finishMessage()
271+
wsCallbacks = wsCallbacks.filter((cb) => cb !== wsCallback)
272+
}
273+
} else {
274+
console.debug('json error', error)
275+
}
276+
}
277+
}
278+
}
279+
wsCallbacks.push(wsCallback)
280+
const { conversationId, wsRequestId } = await sendWebsocketConversation(accessToken, options)
281+
session.conversationId = conversationId
282+
session.wsRequestId = wsRequestId
283+
port.postMessage({ session: session })
284+
} else {
285+
await fetchSSE(url, {
286+
...options,
287+
onMessage(message) {
288+
console.debug('sse message', message)
289+
if (message.trim() === '[DONE]') {
290+
finishMessage()
291+
return
292+
}
293+
let data
294+
try {
295+
data = JSON.parse(message)
296+
} catch (error) {
297+
console.debug('json error', error)
298+
return
299+
}
300+
handleMessage(data)
301+
},
302+
async onStart() {
303+
// sendModerations(accessToken, question, session.conversationId, session.messageId)
304+
},
305+
async onEnd() {
358306
port.postMessage({ done: true })
359307
cleanController()
308+
},
309+
async onError(resp) {
310+
cleanController()
311+
if (resp instanceof Error) throw resp
312+
if (resp.status === 403) {
313+
throw new Error('CLOUDFLARE')
314+
}
315+
const error = await resp.json().catch(() => ({}))
316+
throw new Error(
317+
!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`,
318+
)
319+
},
320+
})
321+
}
322+
323+
function handleMessage(data) {
324+
if (data.error) {
325+
throw new Error(data.error)
326+
}
327+
328+
if (data.conversation_id) session.conversationId = data.conversation_id
329+
if (data.message?.id) session.parentMessageId = data.message.id
330+
331+
const respAns = data.message?.content?.parts?.[0]
332+
const contentType = data.message?.content?.content_type
333+
if (contentType === 'text' && respAns) {
334+
answer =
335+
generationPrefixAnswer +
336+
(generatedImageUrl && `\n\n![](${generatedImageUrl})\n\n`) +
337+
respAns
338+
} else if (contentType === 'code' && data.message?.status === 'in_progress') {
339+
const generationText = '\n\n' + t('Generating...')
340+
if (answer && !answer.endsWith(generationText)) generationPrefixAnswer = answer
341+
answer = generationPrefixAnswer + generationText
342+
} else if (
343+
contentType === 'multimodal_text' &&
344+
respAns?.content_type === 'image_asset_pointer'
345+
) {
346+
const imageAsset = respAns?.asset_pointer || ''
347+
if (imageAsset) {
348+
fetch(
349+
`${config.customChatGptWebApiUrl}/backend-api/files/${imageAsset.replace(
350+
'file-service://',
351+
'',
352+
)}/download`,
353+
{
354+
credentials: 'include',
355+
headers: {
356+
Authorization: `Bearer ${accessToken}`,
357+
...(cookie && { Cookie: cookie }),
358+
},
359+
},
360+
).then((r) => r.json().then((json) => (generatedImageUrl = json?.download_url)))
360361
}
361-
},
362-
async onError(resp) {
363-
cleanController()
364-
if (resp instanceof Error) throw resp
365-
if (resp.status === 403) {
366-
throw new Error('CLOUDFLARE')
367-
}
368-
const error = await resp.json().catch(() => ({}))
369-
throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`)
370-
},
371-
})
362+
}
363+
364+
if (answer) {
365+
port.postMessage({ answer: answer, done: false, session: null })
366+
}
367+
}
368+
369+
function finishMessage() {
370+
pushRecord(session, question, answer)
371+
console.debug('conversation history', { content: session.conversationRecords })
372+
port.postMessage({ answer: answer, done: true, session: session })
373+
}
372374
}

src/services/init-session.mjs

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { v4 as uuidv4 } from 'uuid'
1616
* @property {string|null} conversationId - chatGPT web mode
1717
* @property {string|null} messageId - chatGPT web mode
1818
* @property {string|null} parentMessageId - chatGPT web mode
19+
* @property {string|null} wsRequestId - chatGPT web mode
1920
* @property {string|null} bingWeb_encryptedConversationSignature
2021
* @property {string|null} bingWeb_conversationId
2122
* @property {string|null} bingWeb_clientId
@@ -63,6 +64,7 @@ export function initSession({
6364
conversationId: null,
6465
messageId: null,
6566
parentMessageId: null,
67+
wsRequestId: null,
6668

6769
// bing
6870
bingWeb_encryptedConversationSignature: null,

0 commit comments

Comments
 (0)