diff --git a/app/client/api.ts b/app/client/api.ts index cecc453baa2..671cb1c4840 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -59,7 +59,7 @@ export interface ChatOptions { config: LLMConfig; onUpdate?: (message: string, chunk: string) => void; - onFinish: (message: string) => void; + onFinish: (message: string, finishedReason?: string) => void; onError?: (err: Error) => void; onController?: (controller: AbortController) => void; onBeforeTool?: (tool: ChatMessageTool) => void; diff --git a/app/client/controller.ts b/app/client/controller.ts index a2e00173dd0..ac5bac7a1e4 100644 --- a/app/client/controller.ts +++ b/app/client/controller.ts @@ -26,6 +26,10 @@ export const ChatControllerPool = { return Object.values(this.controllers).length > 0; }, + getPendingMessageId() { + return Object.keys(this.controllers).map((v) => v.split(",").at(-1)); + }, + remove(sessionId: string, messageId: string) { const key = this.key(sessionId, messageId); delete this.controllers[key]; diff --git a/app/client/platforms/anthropic.ts b/app/client/platforms/anthropic.ts index 7dd39c9cddc..eb3ab8f485b 100644 --- a/app/client/platforms/anthropic.ts +++ b/app/client/platforms/anthropic.ts @@ -262,7 +262,7 @@ export class ClaudeApi implements LLMApi { runTools[index]["function"]["arguments"] += chunkJson?.delta?.partial_json; } - return chunkJson?.delta?.text; + return { delta: chunkJson?.delta?.text }; }, // processToolMessage, include tool_calls message and tool call results ( diff --git a/app/client/platforms/moonshot.ts b/app/client/platforms/moonshot.ts index cd10d2f6c15..3b5590a9f9f 100644 --- a/app/client/platforms/moonshot.ts +++ b/app/client/platforms/moonshot.ts @@ -163,7 +163,7 @@ export class MoonshotApi implements LLMApi { runTools[index]["function"]["arguments"] += args; } } - return choices[0]?.delta?.content; + return { delta: choices[0]?.delta?.content }; }, // processToolMessage, include tool_calls message and tool call results ( diff --git a/app/client/platforms/openai.ts b/app/client/platforms/openai.ts index 664ff872ba3..aff9a2ca2d6 100644 --- a/app/client/platforms/openai.ts +++ b/app/client/platforms/openai.ts @@ -266,6 +266,7 @@ export class ChatGPTApi implements LLMApi { content: string; tool_calls: ChatMessageTool[]; }; + finish_reason?: string; }>; const tool_calls = choices[0]?.delta?.tool_calls; if (tool_calls?.length > 0) { @@ -286,7 +287,10 @@ export class ChatGPTApi implements LLMApi { runTools[index]["function"]["arguments"] += args; } } - return choices[0]?.delta?.content; + return { + delta: choices[0]?.delta?.content, + finishReason: choices[0]?.finish_reason, + }; }, // processToolMessage, include tool_calls message and tool call results ( diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 17f8d3a3496..b2b35c9ce27 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -9,6 +9,7 @@ import React, { RefObject, } from "react"; +import ContinueIcon from "../icons/continue.svg"; import SendWhiteIcon from "../icons/send-white.svg"; import BrainIcon from "../icons/brain.svg"; import RenameIcon from "../icons/rename.svg"; @@ -461,7 +462,16 @@ export function ChatActions(props: { // stop all responses const couldStop = ChatControllerPool.hasPending(); - const stopAll = () => ChatControllerPool.stopAll(); + const stopAll = () => { + const stopList = ChatControllerPool.getPendingMessageId(); + ChatControllerPool.stopAll(); + chatStore.updateCurrentSession( + (session) => + (session.messages = session.messages.map((v) => + stopList.includes(v.id) ? { ...v, finishedReason: "aborted" } : v, + )), + ); + }; // switch model const currentModel = chatStore.currentSession().mask.modelConfig.model; @@ -1045,6 +1055,12 @@ function _Chat() { // stop response const onUserStop = (messageId: string) => { ChatControllerPool.stop(session.id, messageId); + chatStore.updateCurrentSession( + (session) => + (session.messages = session.messages.map((v) => + v.id === messageId ? { ...v, finishedReason: "aborted" } : v, + )), + ); }; useEffect(() => { @@ -1171,6 +1187,18 @@ function _Chat() { inputRef.current?.focus(); }; + const onContinue = (messageID: string) => { + chatStore.updateCurrentSession( + (session) => + (session.messages = session.messages.map((v) => + v.id === messageID ? { ...v, streaming: true } : v, + )), + ); + chatStore + .onContinueBotMessage(messageID) + .finally(() => setIsLoading(false)); + }; + const onPinMessage = (message: ChatMessage) => { chatStore.updateCurrentSession((session) => session.mask.context.push(message), @@ -1724,6 +1752,15 @@ function _Chat() { ) } /> + {["length", "aborted"].includes( + message.finishedReason ?? "", + ) ? ( + } + onClick={() => onContinue(message.id)} + /> + ) : null} )} diff --git a/app/icons/continue.svg b/app/icons/continue.svg new file mode 100644 index 00000000000..d88f263f66e --- /dev/null +++ b/app/icons/continue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/locales/ar.ts b/app/locales/ar.ts index 464519e2055..4a0885b1248 100644 --- a/app/locales/ar.ts +++ b/app/locales/ar.ts @@ -45,6 +45,7 @@ const ar: PartialLocaleType = { Edit: "تحرير", RefreshTitle: "تحديث العنوان", RefreshToast: "تم إرسال طلب تحديث العنوان", + Continue: "استمر", }, Commands: { new: "دردشة جديدة", diff --git a/app/locales/bn.ts b/app/locales/bn.ts index 945c442df57..96fcdeefa44 100644 --- a/app/locales/bn.ts +++ b/app/locales/bn.ts @@ -45,6 +45,7 @@ const bn: PartialLocaleType = { Edit: "সম্পাদনা করুন", RefreshTitle: "শিরোনাম রিফ্রেশ করুন", RefreshToast: "শিরোনাম রিফ্রেশ অনুরোধ পাঠানো হয়েছে", + Continue: "চালিয়ে যান", }, Commands: { new: "নতুন চ্যাট", diff --git a/app/locales/cn.ts b/app/locales/cn.ts index fcbdb6f627c..b606f158c21 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -46,6 +46,7 @@ const cn = { FullScreen: "全屏", RefreshTitle: "刷新标题", RefreshToast: "已发送刷新标题请求", + Continue: "继续", }, Commands: { new: "新建聊天", diff --git a/app/locales/cs.ts b/app/locales/cs.ts index 5a132b3ce6d..ee243b9cbfd 100644 --- a/app/locales/cs.ts +++ b/app/locales/cs.ts @@ -45,6 +45,7 @@ const cs: PartialLocaleType = { Edit: "Upravit", RefreshTitle: "Obnovit název", RefreshToast: "Požadavek na obnovení názvu byl odeslán", + Continue: "Pokračovat", }, Commands: { new: "Nová konverzace", diff --git a/app/locales/de.ts b/app/locales/de.ts index ebe7aff2d71..7f3bb73ef44 100644 --- a/app/locales/de.ts +++ b/app/locales/de.ts @@ -45,6 +45,7 @@ const de: PartialLocaleType = { Edit: "Bearbeiten", RefreshTitle: "Titel aktualisieren", RefreshToast: "Anfrage zur Titelaktualisierung gesendet", + Continue: "Fortsetzen", }, Commands: { new: "Neues Gespräch", diff --git a/app/locales/en.ts b/app/locales/en.ts index 049447569f7..456844a9006 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -47,6 +47,7 @@ const en: LocaleType = { FullScreen: "FullScreen", RefreshTitle: "Refresh Title", RefreshToast: "Title refresh request sent", + Continue: "Continue", }, Commands: { new: "Start a new chat", diff --git a/app/locales/es.ts b/app/locales/es.ts index c1caae2d385..ef66a411b18 100644 --- a/app/locales/es.ts +++ b/app/locales/es.ts @@ -46,6 +46,7 @@ const es: PartialLocaleType = { Edit: "Editar", RefreshTitle: "Actualizar título", RefreshToast: "Se ha enviado la solicitud de actualización del título", + Continue: "Continuar", }, Commands: { new: "Nueva conversación", diff --git a/app/locales/fr.ts b/app/locales/fr.ts index 97fb79e3238..f960eb6151f 100644 --- a/app/locales/fr.ts +++ b/app/locales/fr.ts @@ -45,6 +45,7 @@ const fr: PartialLocaleType = { Edit: "Modifier", RefreshTitle: "Actualiser le titre", RefreshToast: "Demande d'actualisation du titre envoyée", + Continue: "Continuer", }, Commands: { new: "Nouvelle discussion", diff --git a/app/locales/id.ts b/app/locales/id.ts index 1eff667ed9e..64474f51cb1 100644 --- a/app/locales/id.ts +++ b/app/locales/id.ts @@ -45,6 +45,7 @@ const id: PartialLocaleType = { Edit: "Edit", RefreshTitle: "Segarkan Judul", RefreshToast: "Permintaan penyegaran judul telah dikirim", + Continue: "Lanjutkan", }, Commands: { new: "Obrolan Baru", diff --git a/app/locales/it.ts b/app/locales/it.ts index 9b0d965d351..ceac7184b16 100644 --- a/app/locales/it.ts +++ b/app/locales/it.ts @@ -45,6 +45,7 @@ const it: PartialLocaleType = { Edit: "Modifica", RefreshTitle: "Aggiorna titolo", RefreshToast: "Richiesta di aggiornamento del titolo inviata", + Continue: "Continua", }, Commands: { new: "Nuova chat", diff --git a/app/locales/jp.ts b/app/locales/jp.ts index 1511afc2c5f..82fbc4a9119 100644 --- a/app/locales/jp.ts +++ b/app/locales/jp.ts @@ -45,6 +45,7 @@ const jp: PartialLocaleType = { Edit: "編集", RefreshTitle: "タイトルを更新", RefreshToast: "タイトル更新リクエストが送信されました", + Continue: "続ける", }, Commands: { new: "新しいチャット", diff --git a/app/locales/ko.ts b/app/locales/ko.ts index 11e6aa4eb29..56c2ab4c25c 100644 --- a/app/locales/ko.ts +++ b/app/locales/ko.ts @@ -45,6 +45,7 @@ const ko: PartialLocaleType = { Edit: "편집", RefreshTitle: "제목 새로고침", RefreshToast: "제목 새로고침 요청이 전송되었습니다", + Continue: "계속하다", }, Commands: { new: "새 채팅", diff --git a/app/locales/no.ts b/app/locales/no.ts index 6fc8e86c787..291a47a5ab4 100644 --- a/app/locales/no.ts +++ b/app/locales/no.ts @@ -46,6 +46,7 @@ const no: PartialLocaleType = { Edit: "Rediger", RefreshTitle: "Oppdater tittel", RefreshToast: "Forespørsel om titteloppdatering sendt", + Continue: "Fortsette", }, Commands: { new: "Ny samtale", diff --git a/app/locales/pt.ts b/app/locales/pt.ts index c04081a8b36..dad1ed56984 100644 --- a/app/locales/pt.ts +++ b/app/locales/pt.ts @@ -45,6 +45,7 @@ const pt: PartialLocaleType = { Edit: "Editar", RefreshTitle: "Atualizar Título", RefreshToast: "Solicitação de atualização de título enviada", + Continue: "Continuar", }, Commands: { new: "Iniciar um novo chat", diff --git a/app/locales/ru.ts b/app/locales/ru.ts index 0fcf73a249f..1d32be226bc 100644 --- a/app/locales/ru.ts +++ b/app/locales/ru.ts @@ -45,6 +45,7 @@ const ru: PartialLocaleType = { Edit: "Редактировать", RefreshTitle: "Обновить заголовок", RefreshToast: "Запрос на обновление заголовка отправлен", + Continue: "Продолжить", }, Commands: { new: "Новый чат", diff --git a/app/locales/sk.ts b/app/locales/sk.ts index 8f83c3ba73a..286b249e1c6 100644 --- a/app/locales/sk.ts +++ b/app/locales/sk.ts @@ -47,6 +47,7 @@ const sk: PartialLocaleType = { Edit: "Upraviť", RefreshTitle: "Obnoviť názov", RefreshToast: "Požiadavka na obnovenie názvu bola odoslaná", + Continue: "Pokračovať", }, Commands: { new: "Začať nový chat", diff --git a/app/locales/tr.ts b/app/locales/tr.ts index b7f14104750..d89b15091e0 100644 --- a/app/locales/tr.ts +++ b/app/locales/tr.ts @@ -45,6 +45,7 @@ const tr: PartialLocaleType = { Edit: "Düzenle", RefreshTitle: "Başlığı Yenile", RefreshToast: "Başlık yenileme isteği gönderildi", + Continue: "Devam et", }, Commands: { new: "Yeni sohbet", diff --git a/app/locales/tw.ts b/app/locales/tw.ts index b0602a08174..c5bebaceee4 100644 --- a/app/locales/tw.ts +++ b/app/locales/tw.ts @@ -45,6 +45,7 @@ const tw = { Edit: "編輯", RefreshTitle: "刷新標題", RefreshToast: "已發送刷新標題請求", + Continue: "繼續", }, Commands: { new: "新建聊天", diff --git a/app/locales/vi.ts b/app/locales/vi.ts index 1f20e15a0b1..5e9f2485ec3 100644 --- a/app/locales/vi.ts +++ b/app/locales/vi.ts @@ -45,6 +45,7 @@ const vi: PartialLocaleType = { Edit: "Chỉnh sửa", RefreshTitle: "Làm mới tiêu đề", RefreshToast: "Đã gửi yêu cầu làm mới tiêu đề", + Continue: "Tiếp tục", }, Commands: { new: "Tạo cuộc trò chuyện mới", diff --git a/app/store/chat.ts b/app/store/chat.ts index 3bcda75389a..59c22f1493c 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -46,6 +46,7 @@ export type ChatMessage = RequestMessage & { id: string; model?: ModelType; tools?: ChatMessageTool[]; + finishedReason?: string; }; export function createMessage(override: Partial): ChatMessage { @@ -373,8 +374,10 @@ export const useChatStore = createPersistStore( session.messages = session.messages.concat(); }); }, - onFinish(message) { + onFinish(message, finishedReason) { botMessage.streaming = false; + if (finishedReason !== null && finishedReason !== undefined) + botMessage.finishedReason = finishedReason; if (message) { botMessage.content = message; get().onNewMessage(botMessage); @@ -429,6 +432,94 @@ export const useChatStore = createPersistStore( }); }, + async onContinueBotMessage(messageID: string) { + const session = get().currentSession(); + const modelConfig = session.mask.modelConfig; + + // get recent messages + const recentMessages = get().getMessagesWithMemory(messageID); + const messageIndex = get().currentSession().messages.length + 1; + + const botMessage = session.messages.find((v) => v.id === messageID); + + if (!botMessage) { + console.error("[Chat] failed to find bot message"); + return; + } + + const baseContent = botMessage.content; + + const api: ClientApi = getClientApi(modelConfig.providerName); + // make request + api.llm.chat({ + messages: recentMessages, + config: { ...modelConfig, stream: true }, + onUpdate(message) { + botMessage.streaming = true; + if (message) { + botMessage.content = baseContent + message; + } + get().updateCurrentSession((session) => { + session.messages = session.messages.concat(); + }); + }, + onFinish(message, finishedReason) { + botMessage.streaming = false; + if (finishedReason !== null && finishedReason !== undefined) + botMessage.finishedReason = finishedReason; + if (message) { + botMessage.content = baseContent + message; + get().onNewMessage(botMessage); + } + ChatControllerPool.remove(session.id, botMessage.id); + }, + onBeforeTool(tool: ChatMessageTool) { + (botMessage.tools = botMessage?.tools || []).push(tool); + get().updateCurrentSession((session) => { + session.messages = session.messages.concat(); + }); + }, + onAfterTool(tool: ChatMessageTool) { + botMessage?.tools?.forEach((t, i, tools) => { + if (tool.id == t.id) { + tools[i] = { ...tool }; + } + }); + get().updateCurrentSession((session) => { + session.messages = session.messages.concat(); + }); + }, + onError(error) { + const isAborted = error.message?.includes?.("aborted"); + botMessage.content += + "\n\n" + + prettyObject({ + error: true, + message: error.message, + }); + botMessage.streaming = false; + botMessage.isError = !isAborted; + get().updateCurrentSession((session) => { + session.messages = session.messages.concat(); + }); + ChatControllerPool.remove( + session.id, + botMessage.id ?? messageIndex, + ); + + console.error("[Chat] failed ", error); + }, + onController(controller) { + // collect controller for stop/retry + ChatControllerPool.addController( + session.id, + botMessage.id ?? messageIndex, + controller, + ); + }, + }); + }, + getMemoryPrompt() { const session = get().currentSession(); @@ -441,12 +532,17 @@ export const useChatStore = createPersistStore( } }, - getMessagesWithMemory() { + getMessagesWithMemory(messageID?: string) { const session = get().currentSession(); const modelConfig = session.mask.modelConfig; const clearContextIndex = session.clearContextIndex ?? 0; const messages = session.messages.slice(); - const totalMessageCount = session.messages.length; + let messageIdx = session.messages.findIndex((v) => v.id === messageID); + if (messageIdx === -1) messageIdx = session.messages.length; + const totalMessageCount = Math.min( + messageIdx + 1, + session.messages.length, + ); // in-context prompts const contextPrompts = session.mask.context.slice(); diff --git a/app/utils/chat.ts b/app/utils/chat.ts index 7f3bb23c58e..8c04df4fc29 100644 --- a/app/utils/chat.ts +++ b/app/utils/chat.ts @@ -3,7 +3,7 @@ import { UPLOAD_URL, REQUEST_TIMEOUT_MS, } from "@/app/constant"; -import { RequestMessage } from "@/app/client/api"; +import { ChatOptions, RequestMessage } from "@/app/client/api"; import Locale from "@/app/locales"; import { EventStreamContentType, @@ -160,17 +160,21 @@ export function stream( tools: any[], funcs: Record, controller: AbortController, - parseSSE: (text: string, runTools: any[]) => string | undefined, + parseSSE: ( + text: string, + runTools: any[], + ) => { delta?: string; finishReason?: string }, processToolMessage: ( requestPayload: any, toolCallMessage: any, toolCallResult: any[], ) => void, - options: any, + options: ChatOptions, ) { let responseText = ""; let remainText = ""; let finished = false; + let finishedReason: string | undefined; let running = false; let runTools: any[] = []; @@ -254,14 +258,13 @@ export function stream( chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource }, 60); }); - return; } if (running) { return; } console.debug("[ChatAPI] end"); finished = true; - options.onFinish(responseText + remainText); + options.onFinish(responseText + remainText, finishedReason); } }; @@ -333,7 +336,11 @@ export function stream( try { const chunk = parseSSE(msg.data, runTools); if (chunk) { - remainText += chunk; + if (typeof chunk === "string") remainText += chunk; + else { + if (chunk.delta) remainText += chunk.delta; + finishedReason = chunk.finishReason; + } } } catch (e) { console.error("[Request] parse error", text, msg, e);