From bc8c20d78f3ecdd6c4e2b10fbc368181ae523cad Mon Sep 17 00:00:00 2001 From: agnaidev Date: Sun, 14 Jun 2026 15:19:51 +0800 Subject: [PATCH 1/4] - remove unused response gen route - better parse messages when prompt building --- common/prompt.ts | 10 +- common/template-messages.ts | 2 +- common/util.ts | 25 + srv/adapter/claude.ts | 18 +- srv/adapter/gemini.ts | 9 +- srv/adapter/openrouter.ts | 9 - srv/adapter/type.ts | 1 + srv/api/chat/index.ts | 3 +- srv/api/chat/message.ts | 909 +------------------------- web/pages/Chat/components/Message.tsx | 35 +- web/pages/Image/ImageModal.tsx | 6 +- web/store/data/bot-generate.ts | 36 +- web/store/prompt.ts | 2 + 13 files changed, 80 insertions(+), 985 deletions(-) diff --git a/common/prompt.ts b/common/prompt.ts index cb10031b0..e71b9a092 100644 --- a/common/prompt.ts +++ b/common/prompt.ts @@ -257,7 +257,9 @@ export async function createPromptParts( const nextMsgs = opts.messages.slice() for (let i = 0; i < nextMsgs.length; i++) { if (nextMsgs[i].userId) continue - nextMsgs[i] = { ...nextMsgs[i], msg: trimSentence(nextMsgs[i].msg) || nextMsgs[i].msg } + const text = trimSentence(nextMsgs[i].msg) || nextMsgs[i].msg + + nextMsgs[i] = { ...nextMsgs[i], msg: text } } opts.messages = nextMsgs @@ -315,11 +317,7 @@ export type AssembledPrompt = Awaited> * @param lines Always in time-ascending order (oldest to newest) * @returns */ -export async function assemblePrompt( - opts: GenerateRequestV2, - encoder: TokenCounter, - chat?: boolean -) { +export async function assemblePrompt(opts: GenerateRequestV2, encoder: TokenCounter) { const post = createPostPrompt(opts) const template = getTemplate(opts) diff --git a/common/template-messages.ts b/common/template-messages.ts index 44e80a381..f17bb6e97 100644 --- a/common/template-messages.ts +++ b/common/template-messages.ts @@ -6,7 +6,7 @@ import { findLast } from './util' import { GenerateRequestV2 } from '/srv/adapter/type' export async function toChatMessages(req: GenerateRequestV2, counter: TokenCounter) { - const assembled = await assemblePrompt(req, counter, true) + const assembled = await assemblePrompt(req, counter) const { sections } = assembled const { diff --git a/common/util.ts b/common/util.ts index 64fc294ce..fcab126ff 100644 --- a/common/util.ts +++ b/common/util.ts @@ -6,6 +6,31 @@ import type { GenerateRequestV2 } from '/srv/adapter/type' export const PING_INTERVAL_MS = 30000 +export function stopResponse(opts: { text: string; author: string; stops: string[] }) { + let generated = opts.text + + if (opts.author) { + generated = generated.split(`${opts.author}:`).join('').trim() + } + + let index = -1 + let trimmed = opts.stops.reduce((prev, endToken) => { + const idx = generated.indexOf(endToken) + + if (idx === -1) return prev + + const text = generated.slice(0, idx) + if (index === -1 || idx < index) { + index = idx + return text + } + + return prev + }, '') + + return trimmed || generated +} + // this is an edited and inverted ver of https://stackoverflow.com/a/70385497 export function incompleteJson(data: string) { if (data.startsWith('{') && !data.endsWith('}')) return true diff --git a/srv/adapter/claude.ts b/srv/adapter/claude.ts index 8d1769229..c45d24a35 100644 --- a/srv/adapter/claude.ts +++ b/srv/adapter/claude.ts @@ -22,7 +22,7 @@ import { CLAUDE_MODELS, CLAUDE_TEXT_MODELS } from '/common/presets/claude' import { fetchStream } from '/common/requests/stream' import { remapMessages } from './template-chat-payload' import { getMimeTypeBase64 } from '/common/util' -import { stripImageContent, toChatMessages } from '/common/template-messages' +import { stripImageContent } from '/common/template-messages' const CHAT_URL = `https://api.anthropic.com/v1/messages` const TEXT_URL = `https://api.anthropic.com/v1/complete` @@ -455,22 +455,6 @@ const streamCompletion: CompletionGenerator = async function* (opts) { return } -export async function createClaudeChatCompletionV2(opts: AdapterProps) { - let messages = opts.messages - if (!messages) { - const result = await toChatMessages(opts, getTokenCounter('claude', '')) - messages = result.messages - } - - // Last message must be 'thinking' block or role 'user' - const lastMsg = messages?.slice(-1)?.[0] - if (lastMsg?.role === 'assistant') { - lastMsg.role = 'user' - } - - return messages -} - export async function createClaudeChatCompletion(opts: AdapterProps) { const result = { system: '', diff --git a/srv/adapter/gemini.ts b/srv/adapter/gemini.ts index 0179c34ca..fa4f84c30 100644 --- a/srv/adapter/gemini.ts +++ b/srv/adapter/gemini.ts @@ -11,10 +11,8 @@ import { } from '@google/genai' import { remapMessages } from './template-chat-payload' import { getMimeTypeBase64 } from '/common/util' -import { getEncoderByName } from '../tokenize' import { getJsonSchemaPayload } from '/common/guidance/json-schema' import { getStoppingStrings } from '/common/requests/payloads' -import { toChatMessages } from '/common/template-messages' import { LLM_DEBUG } from './util' const SYSTEM_INCAPABLE: Record = { @@ -24,12 +22,7 @@ const SYSTEM_INCAPABLE: Record = { export const handleGemini: ModelAdapter = async function* (opts) { const key = opts.guest ? opts.gen.thirdPartyKey : decryptText(opts.gen.thirdPartyKey!) - const counter = getEncoderByName('gemma') - let messages = opts.messages - if (!messages) { - const res = await toChatMessages(opts, counter.count) - messages = res.messages - } + let messages = opts.messages! const googleModel = opts.gen.thirdPartyModel || opts.gen.googleModel diff --git a/srv/adapter/openrouter.ts b/srv/adapter/openrouter.ts index 019078e83..5f7336fdb 100644 --- a/srv/adapter/openrouter.ts +++ b/srv/adapter/openrouter.ts @@ -4,7 +4,6 @@ import { registerAdapter } from './register' import { ModelAdapter } from './type' import { AppLog } from '../middleware' import { OpenRouterModel } from '/common/adapters' -import { createClaudeChatCompletionV2 } from './claude' import { logPayload } from './template-chat-payload' import { streamGenerator } from '/common/requests/stream' import { getJsonSchemaPayload } from '/common/guidance/json-schema' @@ -73,16 +72,8 @@ export const handleOpenRouter: ModelAdapter = async function* (opts) { payload.response_format = getJsonSchemaPayload(opts.jsonSchema, 'openai', opts) } - const useAnthropic = - opts.gen.service !== 'openrouter-completion' && - (opts.gen.openRouterModel?.id || '').startsWith('anthropic') - if (opts.gen.service === 'openrouter-completion') { payload.prompt = opts.prompt - } else if (useAnthropic) { - const messages = await createClaudeChatCompletionV2(opts) - payload.messages = messages - // payload.system = system } else if (opts.messages) { const last = opts.messages.slice(-1)[0] if (last && modelNeedsUserRoleLast(opts, payload.model)) { diff --git a/srv/adapter/type.ts b/srv/adapter/type.ts index ba8c9f482..17a649473 100644 --- a/srv/adapter/type.ts +++ b/srv/adapter/type.ts @@ -88,6 +88,7 @@ export type GenerateRequestV2 = { user: AppSchema.User members: AppSchema.Profile[] sender: AppSchema.Profile + stop: string[] parts: PromptPlaceholders diff --git a/srv/api/chat/index.ts b/srv/api/chat/index.ts index 385198fba..7bc6bdda7 100644 --- a/srv/api/chat/index.ts +++ b/srv/api/chat/index.ts @@ -13,7 +13,7 @@ import { getAllChats, getCharacterChats, getChatDetail } from './get' import { guestGenerateMsg } from './guest-msg' import { getImageModelList, getSdModelList } from './image' import { createInvite, acceptInvite, rejectInvite, getInvites, uninviteMember } from './invite' -import { generateMessageV2, getMessages, createMessage } from './message' +import { getMessages, createMessage } from './message' import { deleteChat, deleteMessages, deleteMessagesV2 } from './remove' import { textToSpeech } from './texttospeech' import { addCharacter, upsertTempCharacter, removeCharacter } from './characters' @@ -32,7 +32,6 @@ router.post('/sd-models', getSdModelList) router.post('/image-models', getImageModelList) router.post('/embed-texts', embedText) router.post('/:id/send', createMessage) -router.post('/:id/generate', generateMessageV2) router.post('/:id/guest-message', guestGenerateMsg) router.post('/:id/image', generateAppImage) router.post('/:id/voice', textToSpeech) diff --git a/srv/api/chat/message.ts b/srv/api/chat/message.ts index a5e04d016..921930534 100644 --- a/srv/api/chat/message.ts +++ b/srv/api/chat/message.ts @@ -1,22 +1,11 @@ -import { UnwrapBody, assertValid } from '/common/valid' import { store } from '../../db' -import { createChatStream, getGenerationSettings } from '../../adapter/generate' -import { AppRequest, StatusError, errors, handle } from '../wrap' -import { sendGuest, sendMany, sendOne } from '../ws' -import { obtainLock, releaseLock } from './lock' +import { StatusError, errors, handle } from '../wrap' +import { sendGuest, sendMany } from '../ws' import { AppSchema } from '../../../common/types/schema' import { v4 } from 'uuid' import { getScenarioEventType } from '/common/scenario' -import { parsePartialJson, round } from '/common/util' -import { JsonOutput, resolveScenario } from '/common/prompt' -import { mapPresetsToAdapter } from '/common/presets' -import { isDefaultTemplate, templates } from '/common/presets/templates' -import { Response } from 'express' -import { getAdapter } from '/common/adapters' -import { getResponseVariable, prepareJsonSchema } from '/common/guidance/json-schema' - -type GenRequest = UnwrapBody -type MsgEntities = Awaited> +import { JsonOutput } from '/common/prompt' +import { assertValid } from '/common/valid' const sendValidator = { // kind: [ @@ -37,59 +26,6 @@ const sendValidator = { meta: 'any?', } as const -const genValidator = { - requestId: 'string?', - v: 'number?', - parent: 'string?', - kind: [ - 'send', - 'send-event:world', - 'send-event:character', - 'send-event:hidden', - 'send-event:ooc', - 'ooc', - 'retry', - 'continue', - 'self', - 'summary', - 'request', - 'chat-query', - ], - char: 'any', - sender: 'any', - members: ['any'], - user: 'any', - chat: 'any', - replacing: 'any?', - replyAs: 'any?', - continuing: 'any?', - characters: 'any?', - impersonate: 'any?', - parts: { - scenario: 'string?', - persona: 'string', - greeting: 'string?', - memory: 'any?', - sampleChat: ['string?'], - post: ['string'], - allPersonas: 'any?', - chatEmbeds: 'any?', - userEmbeds: 'any?', - }, - lines: ['string'], - linesCount: 'number?', - text: 'string?', - settings: 'any?', - lastMessage: 'string?', - chatEmbeds: 'any?', - userEmbeds: 'any?', - imageData: 'string?', - jsonSchema: 'any?', - jsonValues: 'any?', - response: 'string?', - eventStream: 'boolean?', -} as const - export const getMessages = handle(async ({ userId, params, query }) => { const chatId = params.id @@ -147,339 +83,6 @@ export const createMessage = handle(async (req) => { return { success: true, message: userMsg } }) -export const generateMessageV2 = handle(async (req, res) => { - const { userId, body, params, log } = req - const chatId = params.id - assertValid(genValidator, body) - - // if (isGuest(req)) { - // return handleGuestGenerate(body, req, res) - // } - - if (req.authed) { - body.user = req.authed - } - - if (body.eventStream) { - res.setHeader('Cache-Control', 'no-cache') - res.setHeader('Content-Type', 'text/event-stream') - res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Connection', 'keep-alive') - res.flushHeaders() - } - - const ents = await getMessageEntities(req, res) - const { requestId, messageId, chat, replyAs, impersonate, members } = ents - - if (!ents.guest && body.kind === 'request' && chat.userId !== userId) { - throw errors.Forbidden - } - - // For authenticated users we will verify parts of the payload - let userMsg = await createUserMessage(req, ents) - - if (body.kind === 'ooc' || !replyAs) { - return { success: true } - } - - /** - * For group chats we won't worry about lock integrity. - * We still need to create the user message and broadcast it, - * but if there is a lock in place do not attempt to generate a message. - */ - if (!isGuest(req)) { - // @todo consider locking for guests? - try { - // Do not obtain locks for local requests - if (!body.response) { - await obtainLock(chatId) - } - } catch (ex) { - if (members.length <= 1) throw ex - return res.json({ - requestId, - success: true, - generating: false, - message: 'User message created', - messageId, - created: userMsg, - }) - } - } - - if (body.kind !== 'chat-query') { - sendMsg(ents, { - type: 'message-creating', - chatId, - mode: body.kind, - senderId: userId, - characterId: replyAs._id, - }) - } - - const schemaSrc = ents.preset.jsonSource === 'character' ? replyAs.json : ents.preset.json - const jsonEnabled = ents.preset.jsonEnabled === true || ents.preset.jsonEnabled === 'standard' - const schema = jsonEnabled ? prepareJsonSchema(schemaSrc, body) : undefined - - let hydration: JsonOutput | undefined - let jsonPartial: any - - let generated = body.response || '' - let retries: string[] = [] - let error = false - let adapter = 'local' - let meta: Record = {} - let probs: any - let partial = '' - - // If body.response is defined, it's a "local request" which means the browser handled the generation. - // When undefined, we'll generate the response - let signal: AbortController | null = new AbortController() - - if (body.response === undefined) { - const listener = () => { - if (!signal) return - if (generated) return - - signal.abort() - - sendMsg(ents, { - type: 'message-error', - error: 'inference cancelled by user', - adapter, - chatId, - requestId, - }) - - res.status(499).end() - } - - if (body.eventStream) { - req.socket.on('end', listener) - } - - setTextStreamHeaders(res, ents, body, userMsg) - - const started = Date.now() - const chatStream = await createChatStream( - { - ...body, - linesCount: body.linesCount, - chat, - replyAs, - impersonate, - requestId, - settings: ents.preset, - book: ents.book, - resolvedScenario: ents.resolvedScenario, - chatSchema: schema, - signal, - }, - log, - isGuest(req) ? req.socketId : undefined - ).catch((err) => ({ err })) - - if ('err' in chatStream) { - req.log.error({ err: chatStream.err, chatId }, 'Chat stream failed to start') - await releaseLock(chatId) - - if (body.eventStream) { - const msg = - chatStream.err?.message || 'Unexpected error occurred when initiating chat response' - - sendMsg(ents, { - type: 'message-error', - requestId, - chatId, - adapter, - error: msg, - }) - return - } else { - throw chatStream.err - } - } - - const { stream, conn, ...metadata } = chatStream - - adapter = metadata.adapter - - meta = { - tts: 0, - ctx: metadata.settings.maxContextLength, - char: metadata.size, - len: metadata.length, - } - log.setBindings({ - adp: adapter, - svc: conn.service, - fmt: conn.format, - prv: conn.provider?.provider, - }) - - try { - const responseVar = getResponseVariable(body) - const aliases = { [responseVar]: 'response' } - - for await (const gen of stream) { - if (!signal) { - break - } - - if (signal.signal.aborted) { - log.info(`Message aborted by user`) - - generated = partial - // error = true - break - } - - if (typeof gen === 'string') { - if (meta.tts === 0) meta.tts = round((Date.now() - started) / 1000) - generated = gen - continue - } - - if ('gens' in gen) { - retries = gen.gens - } - - if ('tokens' in gen) { - if (meta.tts === 0) meta.tts = round((Date.now() - started) / 1000) - generated = gen.tokens as string - break - } - - if ('partial' in gen) { - if (meta.tts === 0) meta.tts = round((Date.now() - started) / 1000) - const prefix = body.kind === 'continue' ? `${body.continuing.msg} ` : '' - if (metadata.json && schema) { - jsonPartial = parsePartialJson(gen.partial, aliases) || jsonPartial - hydration = schema?.hydrator(jsonPartial || {}) - } - - partial = `${prefix}${gen.partial}` - - sendMsg(ents, { - requestId: body.requestId, - type: 'message-partial', - kind: body.kind, - partial: hydration ? hydration.response : `${prefix}${gen.partial}`, - json: hydration, - adapter, - chatId, - }) - continue - } - - if ('meta' in gen) { - Object.assign(meta, gen.meta) - continue - } - - if ('prompt' in gen) { - sendMsgOne(ents, { type: 'service-prompt', id: messageId, prompt: gen.prompt }) - continue - } - - if ('error' in gen) { - error = true - sendMsg(ents, { type: 'message-error', requestId, error: gen.error, adapter, chatId }) - continue - } - - if ('warning' in gen) { - sendMsgOne(ents, { type: 'message-warning', requestId, warning: gen.warning }) - } - } - } catch (ex: any) { - error = true - generated = partial - - if (ex?.name === 'AbortError') { - signal = null - error = false - // Intentional NOOP - This is a user cancellation or request interruption - } else if (ex instanceof StatusError) { - error = true - log.warn({ err: ex }, `[${ex.status}] Stream handler exception`) - sendMsg(ents, { - type: 'message-error', - requestId, - error: `[${ex.status}] Message failed: ${ex?.message || ex}`, - adapter, - chatId, - }) - } else { - log.error({ err: ex }, 'Unhandled exception occurred during stream handler') - sendMsg(ents, { - type: 'message-error', - requestId, - error: `Unhandled exception: ${ex?.message || ex}`, - adapter, - chatId, - }) - } - } - - req.socket.removeAllListeners('end') - - generated = body.kind === 'continue' ? `${body.continuing.msg} ${generated}` : generated - if (hydration?.response) { - generated = hydration.response - } - - signal = null - - if (!ents.guest) { - await releaseLock(chatId) - } - } - - if (error) { - if (body.eventStream) { - res.write(`data: [DONE]\n\n`) - res.end() - } - return - } - - const parent = getNewMessageParent(body, userMsg) - - if (hydration?.response) { - generated = hydration.response - } - - if (meta.probs) { - probs = meta.probs - delete meta.probs - } - - const payload = { - req, - ents, - meta, - probs, - responseText: generated, - parent, - hydration, - adapter, - retries, - } - if (ents.guest) { - await handleGuestResponse(payload) - } else { - await handleAuthedResponse(payload) - } - - if (!res.writableEnded) { - try { - res.write('data: [DONE]\n\n') - res.end() - } catch (ex) {} - } -}) - function newMessage( messageId: string, chatId: string, @@ -544,507 +147,3 @@ async function ensureBotMembership( update.characters = characters await store.chats.update(chat._id, update) } - -function getNewMessageParent(body: GenRequest, userMsg: AppSchema.ChatMessage | undefined): string { - switch (body.kind) { - case 'continue': { - return body.continuing?.parent - } - - case 'summary': - case 'chat-query': - return '' - - case 'retry': - case 'request': - return body.parent || '' - - case 'ooc': - case 'self': - case 'send': - case 'send-event:character': - case 'send-event:hidden': - case 'send-event:ooc': - case 'send-event:world': - return userMsg?._id || '' - } -} - -async function handleAuthedResponse(opts: { - req: AppRequest - ents: MsgEntities - responseText: string - retries: string[] - parent: string - meta: any - hydration: any - adapter: string - probs: any -}) { - const { req, responseText, parent, meta, hydration, ents, adapter, retries, probs } = opts - const { chatId, replyAs, requestId, senderId } = ents - const body = req.body - - const updatedAt = new Date().toISOString() - let treeLeafId = '' - - switch (body.kind) { - case 'summary': { - sendMsgOne(ents, { type: 'chat-summary', chatId: ents.chatId, summary: responseText }) - break - } - - case 'chat-query': { - sendMsgOne(ents, { - type: 'chat-query', - requestId: body.requestId, - chatId, - response: responseText, - }) - break - } - - case 'self': - case 'request': - case 'send-event:world': - case 'send-event:character': - case 'send-event:hidden': - case 'send': { - const msg = await store.msgs.createChatMessage({ - _id: requestId, - chatId, - characterId: replyAs._id, - senderId, - message: responseText, - adapter, - ooc: false, - meta, - retries, - event: undefined, - parent, - json: hydration, - name: replyAs.name, - }) - - msg.meta.probs = probs - - sendMsg(ents, { - type: 'message-created', - requestId, - msg, - chatId, - adapter, - generate: true, - json: hydration, - }) - treeLeafId = requestId - break - } - - case 'retry': { - if (body.replacing) { - const nextRetries = [body.replacing.msg] - .concat(retries) - .concat(body.replacing.retries || []) - - const next = await store.msgs.editMessage(body.replacing._id, { - msg: responseText, - adapter, - meta, - state: 'retried', - retries: nextRetries, - parent: body.parent, - json: hydration ? hydration : (null as any), - }) - treeLeafId = body.replacing._id - meta.probs = probs - sendMsg(ents, { - type: 'message-retry', - requestId, - chatId, - messageId: body.replacing._id, - message: next?.msg, - retries: next?.retries, - adapter, - generate: true, - meta, - updatedAt: next?.updatedAt, - json: hydration, - }) - } else { - const msg = await store.msgs.createChatMessage({ - _id: requestId, - chatId, - characterId: replyAs._id, - message: responseText, - adapter, - ooc: false, - meta, - retries, - event: undefined, - parent, - json: hydration, - name: replyAs.name, - }) - msg.meta.probs = probs - treeLeafId = requestId - sendMsg(ents, { - type: 'message-created', - requestId, - msg, - chatId, - adapter, - generate: true, - json: hydration, - }) - } - break - } - - case 'continue': { - const next = await store.msgs.editMessage(body.continuing._id, { - msg: responseText, - adapter, - meta, - state: 'continued', - }) - treeLeafId = body.continuing._id - meta.probs = probs - sendMsg(ents, { - type: 'message-retry', - requestId, - chatId, - messageId: body.continuing._id, - message: responseText, - adapter, - generate: true, - retries: next?.retries, - meta, - updatedAt, - }) - break - } - } - - if (treeLeafId) { - await store.chats.update(chatId, { treeLeafId, updatedAt }) - } else { - await store.chats.update(chatId, { updatedAt }) - } -} - -async function handleGuestResponse(opts: { - req: AppRequest - ents: MsgEntities - responseText: string - retries: string[] - parent: string - meta: any - hydration: any - adapter: string -}) { - const { req, responseText, parent, meta, hydration, ents } = opts - const body = req.body - let retries = opts.retries.slice() - if (body.kind === 'retry' && body.replacing) { - retries = [body.replacing.msg].concat(retries).concat(body.replacing.retries || []) - } - - const response = newMessage(ents.messageId, ents.chatId, responseText, { - characterId: ents.replyAs._id, - userId: ents.senderId, - ooc: false, - meta, - event: undefined, - retries, - parent, - json: hydration, - }) - - switch (body.kind) { - case 'summary': - sendMsgOne(ents, { type: 'chat-summary', chatId: ents.chatId, summary: responseText }) - return - - case 'continue': - case 'request': - case 'retry': - case 'self': - case 'send': - case 'send-event:world': - case 'send-event:character': - case 'send-event:hidden': - sendMsgOne(ents, { - type: 'guest-message-created', - requestId: ents.requestId, - msg: response, - chatId: ents.chatId, - adapter: opts.adapter, - continue: body.kind === 'continue', - generate: true, - meta, - json: hydration, - }) - return - } -} - -async function getMessageEntities(req: AppRequest, res: Response) { - const { body, userId } = req - const requestId = body.requestId || v4() - const messageId = - body.kind === 'retry' - ? body.replacing?._id ?? requestId - : body.kind === 'continue' - ? body.continuing?._id - : requestId - const version = req.body.v || 1 - - if (isGuest(req)) { - const replyAs = body.replyAs || body.char - const chat = body.chat - if (!chat) throw errors.ChatNotFound - const impersonate = body.impersonate - - return { - version, - guest: true, - requestId, - messageId, - socketId: req.socketId, - user: body.user, - chat, - chatId: req.params.id, - mainCharacter: body.char, - replyAs, - impersonate, - preset: body.settings, - members: [] as string[], - book: undefined, - resolvedScenario: undefined, - senderId: body.kind === 'self' ? 'anon' : undefined, - socketIds: [req.socketId], - sse: (payload: object) => { - if (!body.eventStream) return - if (res.closed) return - if (!res.writable) return - - res.write(`data: ${JSON.stringify(payload)}\n\n`) - }, - } - } - - const impersonateId: string | undefined = body.impersonate?._id - const impersonate: AppSchema.Character | undefined = !impersonateId - ? undefined - : impersonateId.startsWith('temp-') - ? body.impersonate - : await store.characters.getCharacter(userId, impersonateId) - - const chat = await store.chats.getChatOnly(req.params.id) - if (!chat) throw errors.ChatNotFound - - const mainCharacter = await store.characters.getCharacter(chat.userId, body.char._id) - if (!mainCharacter) { - throw errors.CharacterNotFound - } - - const replyAs: AppSchema.Character = body.replyAs._id.startsWith('temp-') - ? body.replyAs - : await store.characters.getCharacter(chat.userId, body.replyAs._id || body.char._id) - - if (chat.userId !== userId) { - const isAllowed = await store.chats.canViewChat(userId, chat) - if (!isAllowed) throw errors.Forbidden - } - - const user = await store.users.getUser(chat.userId) - if (!user) { - throw errors.Forbidden - } - - const { adapter } = getAdapter(chat, user, body.settings) - const settings = await getGenerationSettings(user, chat, adapter).then((gen) => { - if (gen.src) { - req.log.setBindings({ g_src: gen.src, g_id: chat.genPreset }) - } - - mapPresetsToAdapter(gen, adapter) - return gen - }) - - if (settings.promptTemplateId) { - if (isDefaultTemplate(settings.promptTemplateId)) { - settings.gaslight = templates[settings.promptTemplateId] - } else { - const template = await store.presets.getTemplate(settings.promptTemplateId) - if (template?.userId === chat.userId) { - settings.gaslight = template.template - } - } - } - - // `temporary` is client-side managed, so keep the value from the request - settings.temporary = body.settings.temporary - - const members = chat.memberIds.concat(chat.userId) - if (body.kind == 'send' || body.kind === 'ooc') { - await ensureBotMembership(chat, members, impersonate) - } - - if (body.kind === 'retry' && req.userId !== chat.userId) { - throw errors.Forbidden - } - - if (body.kind === 'continue' && req.userId !== chat.userId) { - throw errors.Forbidden - } - - const book = chat.memoryId ? await store.memory.getBook(chat.memoryId) : undefined - const chatScenarios = chat.scenarioIds - ? await store.scenario.getScenariosById(chat.scenarioIds) - : [] - const resolvedScenario = resolveScenario(chat, mainCharacter, chatScenarios) - - return { - version, - guest: false, - requestId, - messageId, - socketId: req.socketId, - user, - chat, - preset: settings, - chatId: req.params.id, - replyAs, - impersonate, - members, - book, - resolvedScenario, - senderId: body.kind === 'self' ? req.userId : undefined, - socketIds: version >= 2 ? members.filter((mem) => mem !== req.userId) : members, - sse: (payload: object) => { - if (!body.eventStream) return - if (res.closed) return - if (!res.writable) return - - res.write(`data: ${JSON.stringify(payload)}\n\n`) - }, - } -} - -async function createUserMessage(req: AppRequest, ents: MsgEntities) { - const { body } = req - const { chatId, replyAs, impersonate } = ents - let userMsg: AppSchema.ChatMessage | undefined - - // If body.response is provided, it's a local request - // We don't need to do anything in this case - if (ents.guest) { - if (req.body.response) return - if (req.body.kind === 'send' || req.body.kind === 'ooc') { - userMsg = newMessage(v4(), chatId, req.body.text!, { - userId: 'anon', - characterId: req.body.impersonate?._id, - ooc: body.kind === 'ooc', - event: undefined, - parent: body.parent, - }) - } else if (body.kind.startsWith('send-event:')) { - userMsg = newMessage(v4(), chatId, body.text!, { - characterId: replyAs?._id, - ooc: false, - event: getScenarioEventType(body.kind), - parent: body.parent, - }) - } - - if (userMsg) { - sendMsg(ents, { type: 'message-created', msg: userMsg, chatId }) - } - - return userMsg - } - - if (body.kind === 'send' || body.kind === 'ooc') { - userMsg = await store.msgs.createChatMessage({ - chatId, - message: body.text!, - characterId: impersonate?._id, - senderId: req.userId, - ooc: body.kind === 'ooc', - event: undefined, - parent: body.parent, - name: impersonate?.name, - }) - - sendMsg(ents, { type: 'message-created', msg: userMsg, chatId }) - } else if (body.kind.startsWith('send-event:')) { - userMsg = await store.msgs.createChatMessage({ - chatId, - message: body.text!, - characterId: replyAs?._id, - senderId: undefined, - ooc: false, - event: getScenarioEventType(body.kind), - parent: body.parent, - name: replyAs?.name, - }) - sendMsg(ents, { type: 'message-created', msg: userMsg, chatId }) - } - - if (userMsg) { - await store.chats.update(chatId, { treeLeafId: userMsg._id, updatedAt: userMsg.updatedAt }) - } - - return userMsg -} - -async function sendMsg(ents: MsgEntities, payload: T) { - const next = { requestId: ents.requestId, ...payload } - if (ents.version > 1) ents.sse(next) - - if (ents.guest) { - if (ents.version > 1) return - return sendGuest(ents.socketId, next) - } - - // The socket IDs does not include the caller socket ID if v2+ requests - return sendMany(ents.socketIds, next) -} - -async function sendMsgOne(ents: MsgEntities, payload: T) { - const next = { requestId: ents.requestId, ...payload } - if (ents.version > 1) { - ents.sse(next) - return - } - - if (ents.guest) { - return sendGuest(ents.socketId, next) - } - - return sendOne(ents.socketId, next) -} - -function isGuest(req: AppRequest) { - return !req.userId -} - -function setTextStreamHeaders(res: Response, ents: MsgEntities, body: GenRequest, userMsg?: any) { - const success = { - requestId: ents.requestId, - success: true, - generating: true, - message: 'Generating message', - messageId: ents.messageId, - created: userMsg, - } - - if (!body.eventStream) { - res.json(success) - return - } - - res.write(`data: ${JSON.stringify(success)}\n\n`) -} diff --git a/web/pages/Chat/components/Message.tsx b/web/pages/Chat/components/Message.tsx index 6b9e748fa..75dfcecf9 100644 --- a/web/pages/Chat/components/Message.tsx +++ b/web/pages/Chat/components/Message.tsx @@ -51,7 +51,7 @@ import { import { markdown } from '../../../shared/markdown' import Button, { ButtonSchema } from '/web/shared/Button' import { ChatContext, useAppContext } from '/web/store/context' -import { hydrateTemplate, trimSentence } from '/common/util' +import { hydrateTemplate, stopResponse, trimSentence } from '/common/util' import { EVENTS, events } from '/web/emitter' import { Pill } from '/web/shared/Card' import { DropMenu } from '/web/shared/DropMenu' @@ -1345,7 +1345,9 @@ function getMessageContent( (name) => name !== sender + ':' ) - const trimmed = stopResponse({ text: message, author: sender, stops: allStops }) + const trimmed = msg.userId + ? message + : stopResponse({ text: message, author: sender, stops: allStops }) return { type: 'message' as const, @@ -1355,35 +1357,6 @@ function getMessageContent( } } -function stopResponse(opts: { text: string; author: string; stops: string[] }) { - let generated = opts.text - - let index = -1 - let trimmed = opts.stops.reduce((prev, endToken) => { - const idx = generated.indexOf(endToken) - - if (idx === -1) return prev - - const text = generated.slice(0, idx) - if (index === -1 || idx < index) { - index = idx - return text - } - - return prev - }, '') - - if (index === -1) { - if (generated.startsWith(`${opts.author}:`)) { - generated = generated.slice(opts.author.length + 1) - } - - return generated.trim() - } - - return trimmed || generated -} - function getJsonUpdate(ctx: ChatContext, def: AppSchema.Character['json'], json: any) { if (!def) return const hydration = hydrateTemplate(def, json, { diff --git a/web/pages/Image/ImageModal.tsx b/web/pages/Image/ImageModal.tsx index a26a82147..74d3e5665 100644 --- a/web/pages/Image/ImageModal.tsx +++ b/web/pages/Image/ImageModal.tsx @@ -1,5 +1,5 @@ import './images.scss' -import { Component, For, JSX, Show, createEffect, createMemo, on } from 'solid-js' +import { Component, For, JSX, Show, createEffect, createMemo, on, onMount } from 'solid-js' import Modal from '../../shared/Modal' import { ConfirmAction, @@ -467,6 +467,10 @@ const PromptSettings: Component<{ return true }) + onMount(() => { + promptStore.loadImageHint(msgs.message.msg.chatId) + }) + return (
diff --git a/web/store/data/bot-generate.ts b/web/store/data/bot-generate.ts index 06de3a9a9..aaf0768d2 100644 --- a/web/store/data/bot-generate.ts +++ b/web/store/data/bot-generate.ts @@ -36,7 +36,7 @@ import { msgsApi } from './messages' import { getProvider } from '../preset-context' import { getLocalPayload, getStoppingStrings } from '/common/requests/payloads' import { toastStore } from '../toasts' -import { inline, LazyPromise, lazyPromise, round } from '/common/util' +import { inline, LazyPromise, lazyPromise, round, stopResponse } from '/common/util' import type { ResponseState } from '../response' import { EVENTS, events } from '/web/emitter' import { debug } from '/common/debug' @@ -178,10 +178,11 @@ async function streamResponse(opts: StreamOpts) { prefix += ' ' } - const stops = getStoppingStrings(req.request, req.entities.settings) const sanitize = (text: string) => (text || '').trim() const format = req.request.settings?.modelFormat + const stops = req.props.stops + if (stops.length < 4 && format) { const tags = BUILTIN_FORMATS[format] if (tags?.closeBot?.trim()) stops.push(tags.closeBot) @@ -601,10 +602,11 @@ async function buildChatRequest(opts: GenerateOpts) { reschemaPrompt: props.reschemaPrompt, eventStream: true, jsonSchema: schema ? { fields: schema.schema, entities: schema.entities } : undefined, + stop: [], } - const stops = getStoppingStrings(request, request.settings) - request.settings!.stopSequences = stops + request.stop = getStoppingStrings(request, request.settings) + request.settings!.stopSequences = request.stop if ( opts.kind === 'send' || @@ -909,6 +911,7 @@ export type GenerateProps = { parent?: AppSchema.ChatMessage json: Record reschemaPrompt?: string + stops: string[] } async function getGenerateProps(opts: GenerateOpts, active: ChatDetail) { @@ -934,13 +937,36 @@ async function getGenerateProps(opts: GenerateOpts, active: ChatDetail) { if (curr.characterId) return curr }, undefined) + const stops = getStoppingStrings( + { + user: entities.user, + char: entities.char, + characters: entities.characters, + impersonate: entities.impersonating, + replyAs: entities.char, + members: entities.members, + sender: entities.profile, + }, + entities.conn.preset + ) + + entities.messages = entities.messages.map((msg) => { + if (!msg.characterId) return msg + if (msg.userId) return msg + + const author = entities.characters[msg.characterId]?.name || '' + const text = stopResponse({ text: msg.msg, author, stops }) + return { ...msg, msg: text } + }) + const props: GenerateProps = { entities, replyAs: entities.char, - messages: entities.messages.slice(), + messages: entities.messages, impersonate: entities.impersonating, parent: getMessageParent(opts.kind, entities.messages), json, + stops, } if (opts.kind === 'chat-query' && entities.presets.json) { diff --git a/web/store/prompt.ts b/web/store/prompt.ts index 668e0e436..9a7a633d3 100644 --- a/web/store/prompt.ts +++ b/web/store/prompt.ts @@ -56,6 +56,8 @@ export const promptStore = createStore( return { imageHint: opts.text } }, loadImageHint: (_, chatId: string) => { + if (!chatId) return { imageHint: '' } + const id = `image-hint-${chatId}` const prev = storage.localGetItem(id) if (prev) { From c176d5bf872c8731d4709647723cc17a439021de Mon Sep 17 00:00:00 2001 From: agnaidev Date: Sun, 14 Jun 2026 15:30:24 +0800 Subject: [PATCH 2/4] - messages required on inference calls --- srv/adapter/agnaistic.ts | 4 +- srv/adapter/chat-completion.ts | 252 +-------------------------- srv/adapter/claude.ts | 12 +- srv/adapter/generate.ts | 2 +- srv/adapter/kobold.ts | 4 +- srv/adapter/openai.ts | 20 +-- srv/adapter/type.ts | 2 +- srv/api/chat/granular.ts | 167 ------------------ srv/api/chat/inference.ts | 4 +- tests/chat-model-sample-chat.spec.ts | 176 ------------------- web/store/data/inference.ts | 2 +- 11 files changed, 16 insertions(+), 629 deletions(-) diff --git a/srv/adapter/agnaistic.ts b/srv/adapter/agnaistic.ts index af3fed840..b5318ad8e 100644 --- a/srv/adapter/agnaistic.ts +++ b/srv/adapter/agnaistic.ts @@ -274,13 +274,13 @@ export const handleAgnaistic: ModelAdapter = async function* (opts) { body.api_key = key - const stripped = body.messages ? stripImageContent(body.messages) : null + const stripped = body.messages?.length ? stripImageContent(body.messages) : null yield { prompt: stripped || prompt } log.debug({ ...body, prompt: null, messages: null, imageData: null }, 'Agnaistic payload') - log.debug(`Prompt:\n${body.messages ? JSON.stringify(stripped, null, 2) : prompt}`) + log.debug(`Prompt:\n${body.messages?.length ? JSON.stringify(stripped, null, 2) : prompt}`) const [submodel, override = ''] = subPreset.subModel.split(',') diff --git a/srv/adapter/chat-completion.ts b/srv/adapter/chat-completion.ts index f38ac1282..17a255bea 100644 --- a/srv/adapter/chat-completion.ts +++ b/srv/adapter/chat-completion.ts @@ -1,18 +1,8 @@ import needle from 'needle' import { AdapterProps, CompletionGenerator, CompletionItem } from './type' -import { defaultPresets } from '/common/default-preset' -import { IMAGE_SUMMARY_PROMPT } from '/common/image' -import { - BOT_REPLACE, - SAMPLE_CHAT_MARKER, - SELF_REPLACE, - ensureValidTemplate, - injectPlaceholders, - insertsDeeperThanConvoHistory, -} from '/common/prompt' -import { AppSchema, TokenCounter } from '/common/types' +import { BOT_REPLACE, SAMPLE_CHAT_MARKER, SELF_REPLACE } from '/common/prompt' +import { TokenCounter } from '/common/types' import { escapeRegex } from '/common/util' -import { toChatMessages } from '/common/template-messages' type SplitSampleChatProps = { sampleChat: string @@ -30,153 +20,6 @@ const sampleChatMarkerCompletionItem: CompletionItem = { content: SAMPLE_CHAT_MARKER.replace('System: ', ''), } -/** - * This function contains the inserts logic for Chat models (Turbo, GPT4...) - * This logic also exists in other places: - * - common/prompt.ts fillPromptWithLines - * - srv/adapter/claude.ts createClaudePrompt - */ -export async function toChatCompletionPayload( - opts: AdapterProps, - counter: TokenCounter, - maxTokens: number -): Promise { - if (opts.kind === 'plain') { - return [{ role: 'system', content: opts.prompt }] - } - - if (opts.gen.thirdPartyFormat === 'openai-chatv2' || opts.gen.thirdPartyFormat === 'gemini') { - const { messages } = await toChatMessages(opts, counter) - return messages - } - - const { lines, gen, replyAs } = opts - - const injectOpts = { - opts, - parts: opts.parts, - lastMessage: opts.lastMessage, - characters: opts.characters || {}, - encoder: counter, - jsonValues: opts.jsonValues, - } - - const messages: CompletionItem[] = [] - const history: CompletionItem[] = [] - - const handle = opts.impersonate?.name || opts.sender?.handle || 'You' - const { parsed: gaslight, inserts } = await injectPlaceholders( - ensureValidTemplate(gen.gaslight || defaultPresets.openai.gaslight, ['history', 'post']), - injectOpts - ) - - messages.push({ role: 'system', content: gaslight }) - - const all = [] - - let maxBudget = - (gen.maxContextLength || defaultPresets.openai.maxContextLength) - - maxTokens - - (await counter([...inserts.values()].join(' '))) - let tokens = await counter(gaslight) - - if (lines) { - all.push(...lines) - } - - // Append 'postamble' and jailbreak - const posts = await getPostInstruction(opts, messages, counter).then((inst) => inst || []) - posts.reverse() - for (const post of posts) { - post.content = await injectPlaceholders(post.content, injectOpts).then((p) => p.parsed) - tokens += await counter(post.content) - history.push(post) - } - - const examplePos = all.findIndex((l) => l.includes(SAMPLE_CHAT_MARKER)) - - let i = all.length - 1 - let addedAllInserts = false - const addRemainingInserts = async () => { - const remainingInserts = insertsDeeperThanConvoHistory(inserts, all.length - i) - if (remainingInserts) { - history.push({ - role: 'system', - content: await injectPlaceholders(remainingInserts, injectOpts).then((i) => i.parsed), - }) - } - } - while (i >= 0) { - const distanceFromBottom = all.length - 1 - i - - const line = all[i] - - const obj: CompletionItem = { - role: 'assistant', - content: line.trim().replace(BOT_REPLACE, replyAs.name).replace(SELF_REPLACE, handle), - } - - const isSystem = line.startsWith('System:') - const isUser = line.startsWith(handle) - const isBot = !isUser && !isSystem - - const insert = inserts.get(distanceFromBottom) - if (insert) - history.push({ - role: 'system', - content: await injectPlaceholders(insert, injectOpts).then((p) => p.parsed), - }) - - if (i === examplePos) { - await addRemainingInserts() - addedAllInserts = true - - const { additions, consumed } = await splitSampleChat( - { - budget: maxBudget - tokens, - sampleChat: obj.content, - char: replyAs.name, - sender: handle, - }, - counter - ) - - if (tokens + consumed > maxBudget) { - --i - continue - } - history.push(...additions.reverse()) - tokens += consumed - --i - continue - } else if (isBot) { - } else if (line === '') { - obj.role = 'system' - obj.content = sampleChatMarkerCompletionItem.content - } else if (isSystem) { - obj.role = 'system' - obj.content = obj.content.replace('System:', '').trim() - } else { - obj.role = 'user' - } - - const length = await counter(obj.content) - if (tokens + length > maxBudget) { - --i - break - } - tokens += length - history.push(obj) - --i - } - if (!addedAllInserts) { - await addRemainingInserts() - } - - const allMessages = messages.concat(history.reverse()) - return allMessages -} - export async function splitSampleChat(opts: SplitSampleChatProps, counter: TokenCounter) { const { sampleChat, char, sender, budget } = opts const regex = new RegExp( @@ -225,97 +68,6 @@ export async function splitSampleChat(opts: SplitSampleChatProps, counter: Token return { additions, consumed: tokens } } -async function getPostInstruction( - opts: AdapterProps, - messages: CompletionItem[], - counter: TokenCounter -): Promise { - let prefix = opts.parts.ujb ?? '' - const role = opts.gen.postUserRole ? 'user' : 'assistant' - - prefix = ( - await injectPlaceholders(prefix, { - opts, - parts: opts.parts, - lastMessage: opts.lastMessage, - characters: opts.characters || {}, - encoder: counter, - jsonValues: opts.jsonValues, - }) - ).parsed - - switch (opts.kind) { - // These cases should never reach here - case 'plain': - case 'ooc': { - return - } - - case 'continue': - return [{ role: 'system', content: `${prefix}\n\nContinue ${opts.replyAs.name}'s response` }] - - case 'summary': { - let content = opts.user.images?.summaryPrompt || IMAGE_SUMMARY_PROMPT.other - - if (!content.startsWith('(')) content = '(' + content - if (!content.endsWith(')')) content = content + ')' - - const looks = Object.values(opts.characters || {}) - .map(getCharLooks) - .filter((v) => !!v) - .join('\n') - - if (looks) { - messages[0].content += '\n' + looks - } - return [{ role: 'user', content }] - } - - case 'self': - return [ - { - role: 'system', - content: `${prefix}\n\n${opts.impersonate?.name || opts.sender?.handle || 'You'}:`, - }, - ] - - case 'retry': - case 'send': - case 'request': { - const appendName = opts.gen.prefixNameAppend ?? true - const messages: CompletionItem[] = [ - { - role: 'system', - content: prefix, - }, - { - role, - content: `${opts.parts.prefill ?? ''}\n\n${appendName ? opts.replyAs.name : ''}:`.trim(), - }, - ] - - // Non-empty-ish messages - return messages.filter((m) => { - if (!m.content) return false - if (m.content === ':') return false - return true - }) - } - } -} - -function getCharLooks(char: AppSchema.Character) { - if (char.persona?.kind === 'text') return - - const visuals = [ - char.persona?.attributes?.looks || '', - char.persona?.attributes?.appearance || '', - ].filter((v) => !!v) - - if (!visuals.length) return - return `${char.name}'s appearance: ${visuals.join(', ')}` -} - export const requestFullCompletion: CompletionGenerator = async function* ({ url, headers, diff --git a/srv/adapter/claude.ts b/srv/adapter/claude.ts index c45d24a35..9f5332186 100644 --- a/srv/adapter/claude.ts +++ b/srv/adapter/claude.ts @@ -13,11 +13,9 @@ import { } from '../../common/prompt' import { AppLog } from '../middleware' import { getTokenCounter } from '../tokenize' -import { toChatCompletionPayload } from './chat-completion' import { sendOne } from '../api/ws' import { joinUrl } from '/common/requests/util' import { GenSettings } from '/common/types/presets' -import { OPENAI_MODELS } from '/common/presets/openai' import { CLAUDE_MODELS, CLAUDE_TEXT_MODELS } from '/common/presets/claude' import { fetchStream } from '/common/requests/stream' import { remapMessages } from './template-chat-payload' @@ -220,7 +218,7 @@ export const handleClaude: ModelAdapter = async function* (opts) { log.debug({ ...payload, prompt: null, messages: null }, 'Claude payload') log.debug(`Prompt:\n${payload.prompt}`) - yield { prompt: payload.messages ? stripImageContent(payload.messages) : payload.prompt } + yield { prompt: payload.messages?.length ? stripImageContent(payload.messages) : payload.prompt } const iterator = payload.stream ? streamCompletion({ @@ -461,14 +459,6 @@ export async function createClaudeChatCompletion(opts: AdapterProps) { messages: opts.messages!, } - if (!result.messages) { - result.messages = await toChatCompletionPayload( - opts, - getTokenCounter('openai', OPENAI_MODELS.Turbo), - opts.gen.maxTokens! - ) - } - // Claude doesn't have a system role, so we extract the first message to put it in the system // field (https://docs.anthropic.com/claude/docs/system-prompts) if (result.messages[0].role === 'system') { diff --git a/srv/adapter/generate.ts b/srv/adapter/generate.ts index acf02cc97..ebbd35474 100644 --- a/srv/adapter/generate.ts +++ b/srv/adapter/generate.ts @@ -49,7 +49,7 @@ export type InferenceRequest = { chatId?: string requestId?: string prompt: string - messages?: Array + messages: Array guest?: string user: AppSchema.User settings?: Partial diff --git a/srv/adapter/kobold.ts b/srv/adapter/kobold.ts index 0551edf0a..2b8c2428f 100644 --- a/srv/adapter/kobold.ts +++ b/srv/adapter/kobold.ts @@ -68,7 +68,7 @@ export const handleThirdParty: ModelAdapter = async function* (opts) { const start = Date.now() const stream = await dispatch(opts, body) - yield { prompt: body.messages ? stripImageContent(body.messages) : body.prompt } + yield { prompt: body.messages?.length ? stripImageContent(body.messages) : body.prompt } let accum = '' let wait = 0 @@ -187,7 +187,7 @@ async function dispatch(opts: AdapterProps, body: any) { case 'ollama': { body.messages = opts.messages body.prompt = undefined - const url = body.messages ? `${baseURL}/chat/completions` : `${baseURL}/completions` + const url = body.messages?.length ? `${baseURL}/chat/completions` : `${baseURL}/completions` return opts.gen.streamResponse ? streamGenerator({ ...base, url, format: opts.gen.thirdPartyFormat }) : fullCompletion({ ...base, url, service: opts.gen.thirdPartyFormat }) diff --git a/srv/adapter/openai.ts b/srv/adapter/openai.ts index 9e13115ff..86e14ccde 100644 --- a/srv/adapter/openai.ts +++ b/srv/adapter/openai.ts @@ -2,15 +2,9 @@ import { getOaiCompatibleUrl, joinUrl } from '/common/requests/util' import { AdapterProps, ChatRole, CompletionItem, ModelAdapter } from './type' import { defaultPresets } from '../../common/presets' import { AppLog } from '../middleware' -import { - modelNeedsUserRoleLast, - requestFullCompletion, - toChatCompletionPayload, -} from './chat-completion' +import { modelNeedsUserRoleLast, requestFullCompletion } from './chat-completion' import { decryptText } from '../db/util' -import { getTokenCounter } from '../tokenize' import { ensureMessagesAlternate, stripImageContent } from '/common/template-messages' -import { OPENAI_MODELS } from '/common/presets/openai' import { streamGenerator } from '/common/requests/stream' import { getStoppingStrings, toImageJinjaTemplate } from '/common/requests/payloads' import { JsonField } from '/common/prompt' @@ -151,16 +145,8 @@ export const handleOAI: ModelAdapter = async function* (opts) { const useChat = (isThirdParty && isChatFormat) || gen.service === 'openai' if (useChat) { - const messages = - gen.thirdPartyFormat !== 'openai-chat' && opts.messages - ? opts.messages - : await toChatCompletionPayload( - opts, - getTokenCounter('openai', OPENAI_MODELS.Turbo), - body.max_tokens - ) - - body.messages = messages + const messages = opts.messages + body.messages = opts.messages /** * @todo provide an option for this diff --git a/srv/adapter/type.ts b/srv/adapter/type.ts index 17a649473..6b38c3a70 100644 --- a/srv/adapter/type.ts +++ b/srv/adapter/type.ts @@ -156,7 +156,7 @@ export type AdapterProps = { sender: AppSchema.Profile prompt: string - messages?: Array<{ role: string; content: string }> + messages: Array<{ role: string; content: string }> parts: PromptPlaceholders diff --git a/srv/api/chat/granular.ts b/srv/api/chat/granular.ts index 84c800445..e69de29bb 100644 --- a/srv/api/chat/granular.ts +++ b/srv/api/chat/granular.ts @@ -1,167 +0,0 @@ -import { Response } from 'express' -import { AppRequest, errors, wrap } from '../wrap' -import { assertValid, UnwrapBody } from '/common/valid' -import { createInferenceStream } from '/srv/adapter/generate' -import { sendGuest, sendMany, sendOne } from '../ws' -import { store } from '/srv/db' - -const validInference = { - requestId: 'string', - chatId: 'string', - messageId: 'string', - messages: ['string?'], - prompt: 'string', - - settings: 'any?', - user: 'any', - presetId: 'string?', - jsonSchema: 'any?', - parentId: 'string?', -} as const - -type ChatRequest = UnwrapBody - -type Ticker = Awaited>['stream'] - -/** - * Save RETRY on handleStreamc complete - * Implement SEND (needs characterId) - * Implement CONTINUE (use retry?) - * - * Thoughts: Don't stream to all other users? - * 1. Send stream result to client - * 2. Client tells API what to do? (Message update/create) - * 3. The Message update/create will automatically notify subscribers? - */ - -export const retryStream = wrap(async (req, res) => { - const { userId, body, socketId } = req - toEventStream(res) - assertValid(validInference, body) - - if (userId) { - if (!req.authed) throw errors.Unauthorized - body.user = req.authed - } - - const signal = new AbortController() - - try { - const { stream } = await createInferenceStream({ - user: body.user!, - log: req.log, - prompt: body.prompt, - messages: body.messages, - settings: body.settings, - guest: userId ? undefined : socketId, - jsonSchema: body.jsonSchema, - signal, - }) - - const result = await handleStream({ req, res, body, stream }) - - result.response - } catch (ex: any) { - if (ex?.name === 'AbortError') { - signal.abort() - } - } finally { - res.write(`[DONE]\n\n`) - res.end() - } -}) - -async function handleStream(opts: { - req: AppRequest - res: Response - body: ChatRequest - stream: Ticker -}) { - let partial = '' - let response = '' - let meta = {} - let error: string | undefined = undefined - let warning - - const members = opts.req.userId - ? await getBroadcastMembers({ userId: opts.req.userId, chatId: opts.body.chatId }) - : [] - - const wrapped = (data: any, userOnly?: boolean) => { - opts.res.write(`data: ${JSON.stringify(data)}\n\n`) - - const payload = { - ...data, - chatId: opts.body.chatId, - messageId: opts.body.messageId, - requestId: opts.req.requestId, - } - - if (!opts.req.userId) { - sendGuest(opts.req.socketId, payload) - return - } - - if (userOnly) { - sendOne(opts.req.userId, payload) - return - } - - sendMany(members, payload) - } - - for await (const gen of opts.stream) { - if (typeof gen === 'string') { - response = gen - continue - } - - if ('meta' in gen) { - Object.assign(meta, gen.meta) - wrapped({ type: 'message-meta', meta: meta }, true) - } - - if ('partial' in gen) { - partial = gen.partial - wrapped({ type: 'message-partial', partial: gen.partial }) - continue - // todo: raise - } - - if ('error' in gen) { - error = gen.error - wrapped({ type: 'message-error', error: gen.error }) - continue - } - - if ('warning' in gen) { - warning = gen.warning - wrapped({ type: 'message-warning', warning: gen.warning }, true) - } - } - - if (!response && partial) { - response = partial - } - - return { response, partial, error, warning, meta } -} - -function toEventStream(res: Response) { - res.setHeader('Cache-Control', 'no-cache') - res.setHeader('Content-Type', 'text/event-stream') - res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Connection', 'keep-alive') - res.flushHeaders() -} - -async function getBroadcastMembers(opts: { chatId: string; userId: string }) { - const chat = await store.chats.getChatOnly(opts.chatId) - - if (!chat) return [] - - if (chat.userId !== opts.userId && !chat.memberIds.includes(opts.userId)) return [] - - const members = chat.memberIds.filter((id) => id !== opts.userId) - return members -} diff --git a/srv/api/chat/inference.ts b/srv/api/chat/inference.ts index 72a653c63..cda30169c 100644 --- a/srv/api/chat/inference.ts +++ b/srv/api/chat/inference.ts @@ -290,6 +290,7 @@ export const guidance = wrap(async ({ userId, log, body, socketId }, res) => { lists: body.lists, reguidance: body.reguidance, requestId: body.requestId, + messages: [], jsonSchema: body.jsonSchema, signal, } @@ -419,7 +420,7 @@ export const inferenceApi = wrap(async (req, res) => { : body.messages ? rendered?.prompt || '' : '', - messages: body.messages, + messages: body.messages || [], user: req.authed!, log: req.log, settings, @@ -532,6 +533,7 @@ export const inference = wrap(async ({ socketId, userId, body, log, get }, res) guest: userId ? undefined : socketId, jsonSchema: body.jsonSchema, imageData: body.imageData, + messages: [], signal, }) diff --git a/tests/chat-model-sample-chat.spec.ts b/tests/chat-model-sample-chat.spec.ts index 46aa93bf5..e69de29bb 100644 --- a/tests/chat-model-sample-chat.spec.ts +++ b/tests/chat-model-sample-chat.spec.ts @@ -1,176 +0,0 @@ -import { expect } from 'chai' -import './init' -import { splitSampleChat, toChatCompletionPayload } from '/srv/adapter/chat-completion' -import { build, reset, toBotMsg, toChar, toChat, toProfile, toUser } from './util' -import { neat } from '/common/util' -import { AdapterProps } from '/srv/adapter/type' -import { getTokenCounter } from '/srv/tokenize' -import { OPENAI_MODELS } from '/common/presets/openai' - -describe('Chat Completion Example Dialogue::', () => { - before(reset) - - it('should properly convert (case insensitive) and split basic messages', async () => { - const input = neat` - Sam: hey there Vader! - Vader: hi Sam!` - const output = await testInput(input) - expect(output).toMatchSnapshot() - }) - - it('should properly understand the first conversation even if it doesnt begin with ', async () => { - const input = neat`Sam: hey there - Vader: hi!` - const output = await testInput(input) - expect(output).toMatchSnapshot() - }) - - it('will interpret everything up to the first name+colon (or ) as a system message if the sampleChat string doesnt begin with a name+colon', async () => { - const input = neat`Vader is nice. - Sam: hey - Vader: hi!` - const output = await testInput(input) - expect(output).toMatchSnapshot() - - const inputWithStart = neat`Vader is nice. - - Sam: hey - Vader: hi!` - const outputWithStart = await testInput(inputWithStart) - expect(outputWithStart).toMatchSnapshot() - }) - - it('should understand when multiple are used in the sample chat and turn them all into the defined System message saying a new conversation has started', async () => { - const input = neat`{{char}} is nice. - - Sam: hey - Vader: hi! - - Vader: bye Sam - Sam: byebye Vader` - const output = await testInput(input) - expect(output).toMatchSnapshot() - }) - - it('should understand that strings written after , up until the first name+colon, should be a system message.', async () => { - const input = neat`Sam: hey - Vader: hi! - - test - Vader: bye Sam - Sam: byebye Vader - Vader: I love you Sam - Sam: me too Vader - Vader is very excited.` - const output = await testInput(input) - expect(output).toMatchSnapshot() - }) - - it('will trim result into budget', async () => { - const input = neat` - Sam: Hey - Vader: Hi! - - test - Vader: This is how I talk - Sam: Oh, interesting! - Vader: I also talk like this! *smiles* But this is far too long to include in our budget! - Sam: More interesting!` - const actual = await testInput(input, 25) - expect(actual).to.matchSnapshot() - }) - - it('should correctly separate out system messages it is explicitly handed, such as a post-sample marker string', async () => { - const input = neat`Sam: hey - Vader: hi! - - Vader: bye Sam - Sam: byebye Vader - System: New conversation started. Previous conversations are examples only.` - const output = await testInput(input) - expect(output).toMatchSnapshot() - }) - - it('will parse random within depth prompt', async () => { - const input = neat` - Vader: hi! - - {{#insert 1}}Random character {{random "a"}}{{/insert}} - Sam: bye! - Vader: bye bye! - ` - const output = await testChat(input) - expect(output).to.matchSnapshot() - }) -}) - -async function testInput(input: string, budget?: number) { - const result = await splitSampleChat( - { - sampleChat: input, - char: TEST_CHARACTER_NAME, - sender: TEST_USER_NAME, - budget, - }, - getTokenCounter('openai', OPENAI_MODELS.Turbo) - ) - - return JSON.stringify(result.additions, null, 2) -} - -const TEST_CHARACTER_NAME = 'Vader' -const TEST_USER_NAME = 'Sam' - -const bot1 = toChar('Sam') -const bot2 = toChar('Vader') -const chat1 = toChat(bot1) -const profile1 = toProfile('Anon') -const user = toUser('User') - -async function testChat(prompt: string) { - const characters = { - [bot1._id]: bot1, - [bot2._id]: bot2, - } - - const parts = await build( - [ - toBotMsg(bot1, 'hi'), - toBotMsg(bot2, 'hihi!'), - toBotMsg(bot1, 'bye'), - toBotMsg(bot2, 'byebye!'), - ], - { char: bot1, replyAs: bot1, chat: chat1, profile: profile1, characters } - ) - - const props: AdapterProps = { - prompt, - conn: {} as any, - char: bot1, - chat: toChat(bot1), - gen: {}, - impersonate: toChar('Vader'), - jsonValues: {}, - kind: 'send', - lines: parts.lines.map((p) => p.msg), - promptLines: parts.lines.map((l) => ({ role: l.role, line: l.msg, type: 'history' })), - log: {} as any, - mappedSettings: {}, - members: [toProfile('Anon')], - parts: parts.parts, - replyAs: bot1, - requestId: '', - sender: profile1, - user: user.user, - characters, - signal: new AbortController(), - isThirdParty: true, - } - - const payload = await toChatCompletionPayload( - props, - getTokenCounter('openai', OPENAI_MODELS.Turbo), - 200 - ) - return payload -} diff --git a/web/store/data/inference.ts b/web/store/data/inference.ts index 9e5b01c7a..6aa6cd5ac 100644 --- a/web/store/data/inference.ts +++ b/web/store/data/inference.ts @@ -383,7 +383,7 @@ export async function inferenceStream(opts: InferenceOpts, onTick?: TickHandler) }) api.localSSE({ host: conn?.url, - path: payload.messages ? `/chat/completions` : '/completions', + path: payload.messages?.length ? `/chat/completions` : '/completions', body: opts.payload || fallback, headers, signal: opts.signal, From 09c4ea9af9202899e0bca488b795893b9c74aa53 Mon Sep 17 00:00:00 2001 From: agnaidev Date: Sun, 14 Jun 2026 19:39:20 +0800 Subject: [PATCH 3/4] use default and preset think tags --- common/reasoning.ts | 47 +++++++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/common/reasoning.ts b/common/reasoning.ts index 5a42566bb..48a668862 100644 --- a/common/reasoning.ts +++ b/common/reasoning.ts @@ -23,11 +23,6 @@ export function extractReasoning( if (!open || !close) return { thoughts: [], content } - const len = { - open: open.length, - close: close.length, - } - const thoughts: string[] = [] if (!content) return { thoughts, content } @@ -35,6 +30,18 @@ export function extractReasoning( const init = { start: content.indexOf(open), end: content.indexOf(close), + open, + close, + } + + if (init.start === -1 && open !== defaults.open) { + init.start = content.indexOf(defaults.open) + init.open = defaults.open + } + + if (init.end === -1 && close !== defaults.close) { + init.end = content.indexOf(defaults.close) + init.close = defaults.close } // No thoughts, skip everything @@ -50,20 +57,32 @@ export function extractReasoning( let start = content.indexOf(open) let end = content.indexOf(close) - if (open !== defaults.open) start = content.indexOf(defaults.open) - if (close !== defaults.close) end = content.indexOf(defaults.close) + const used = { + start: open, + end: close, + } + + if (open !== defaults.open && start === -1) { + start = content.indexOf(defaults.open) + used.start = defaults.open + } + + if (close !== defaults.close && end === -1) { + end = content.indexOf(defaults.close) + used.end = defaults.close + } // Both present, but end comes before start if (start > -1 && end > -1 && start > end) { let pre = content.slice(0, end) - let thought = content.slice(start + len.open) - const nextEnd = thought.indexOf(close) + let thought = content.slice(start + used.start.length) + const nextEnd = thought.indexOf(used.end) // There is another end tag if (nextEnd > -1) { const innerThought = thought.slice(0, nextEnd) - const post = thought.slice(nextEnd + len.close) + const post = thought.slice(nextEnd + used.end.length) content = `${pre.trim()}\n${post.trim()}` thought = innerThought thoughts.push(thought) @@ -77,8 +96,8 @@ export function extractReasoning( // Both tags present if (start > -1 && end > -1) { const pre = content.slice(0, start) - const post = content.slice(end + len.close) - const thought = content.slice(start + len.open, end) + const post = content.slice(end + used.end.length) + const thought = content.slice(start + used.start.length, end) thoughts.push(thought) // Case 1. Only display pre-thought text @@ -102,7 +121,7 @@ export function extractReasoning( // Only opening tag if (start > -1) { const pre = content.slice(0, start) - const thought = content.slice(start + len.open) + const thought = content.slice(start + used.start.length) content = pre thoughts.push(thought) @@ -111,7 +130,7 @@ export function extractReasoning( // Only closing tag if (end > -1) { - const post = content.slice(end + len.close) + const post = content.slice(end + used.end.length) const thought = content.slice(0, end) thoughts.push(thought) content = post From 3bfdcd7a54a4ad224707ac98b0be8041c605f25a Mon Sep 17 00:00:00 2001 From: agnaidev Date: Mon, 15 Jun 2026 13:01:20 +0800 Subject: [PATCH 4/4] optional chaining for startsWith checks --- common/horde-gen.ts | 2 +- common/prompt.ts | 2 +- common/requests/swarmui.ts | 2 +- common/requests/util.ts | 2 +- common/scenario.ts | 4 ++-- common/template-parser.ts | 8 ++++---- common/util.ts | 8 ++++---- srv/adapter/chat-completion.ts | 4 ++-- srv/adapter/claude.ts | 6 +++--- srv/adapter/kobold.ts | 4 ++-- srv/adapter/petals.ts | 2 +- srv/api/classify/index.ts | 2 +- srv/api/upload.ts | 2 +- srv/app.ts | 2 +- srv/db/user.ts | 2 +- srv/image/index.ts | 2 +- web/pages/Character/util.ts | 2 +- web/pages/Chat/MemberModal.tsx | 4 ++-- web/pages/Chat/util.ts | 4 ++-- web/shared/util.ts | 6 +++--- web/store/data/bot-generate.ts | 6 +++--- web/store/event.ts | 4 ++-- web/store/message.ts | 10 +++++----- web/store/response.ts | 2 +- 24 files changed, 46 insertions(+), 46 deletions(-) diff --git a/common/horde-gen.ts b/common/horde-gen.ts index a5e511209..18105073d 100644 --- a/common/horde-gen.ts +++ b/common/horde-gen.ts @@ -145,7 +145,7 @@ export async function generateImage( onTick, }) - if (!image.text.startsWith('data:') && typeof window !== 'undefined') { + if (!image.text?.startsWith('data:') && typeof window !== 'undefined') { image.text = `data:image/png;base64,${image.text}` } diff --git a/common/prompt.ts b/common/prompt.ts index e71b9a092..4b93a554c 100644 --- a/common/prompt.ts +++ b/common/prompt.ts @@ -556,7 +556,7 @@ export async function buildPromptPlaceholders( const temp = opts.chat.tempCharacters?.[bot._id] if (temp?.deletedAt || temp?.favorite === false) continue - if (!bot._id.startsWith('temp-') && !chat.characters?.[bot._id]) { + if (!bot._id?.startsWith('temp-') && !chat.characters?.[bot._id]) { continue } diff --git a/common/requests/swarmui.ts b/common/requests/swarmui.ts index 55839c4df..34adf3a43 100644 --- a/common/requests/swarmui.ts +++ b/common/requests/swarmui.ts @@ -235,7 +235,7 @@ async function getPayload(req: ImageRequestOpts) { function getUrl(opts: { host?: string; path: string; getter?: boolean }) { const affix = opts.getter ? '' : '/API' - const prefix = opts.path.startsWith('/') ? affix : `${affix}/` + const prefix = opts.path?.startsWith('/') ? affix : `${affix}/` const host = opts.host || 'http://localhost:7801' return `${host}${prefix}${opts.path}` diff --git a/common/requests/util.ts b/common/requests/util.ts index 1f3c89027..a0ef88fb0 100644 --- a/common/requests/util.ts +++ b/common/requests/util.ts @@ -143,7 +143,7 @@ export function joinUrl(base: string, path: string) { base = base.slice(0, -1) } - if (path.startsWith('/')) { + if (path?.startsWith('/')) { path = path.slice(1) } diff --git a/common/scenario.ts b/common/scenario.ts index b7c7e7c21..1f500c26c 100644 --- a/common/scenario.ts +++ b/common/scenario.ts @@ -9,14 +9,14 @@ const eventTypes: Record = { export function isScenarioEvent(event?: any): event is AppSchema.ScenarioEventType { if (typeof event !== 'string') return false - if (!event.startsWith('send-event:')) return false + if (!event?.startsWith('send-event:')) return false const [, type] = event.split(':') return !!eventTypes[type as AppSchema.ScenarioEventType] } export function getScenarioEventType(event: string): AppSchema.ScenarioEventType | undefined { - if (!event.startsWith('send-event')) return + if (!event?.startsWith('send-event')) return const [, type] = event.split(':') as AppSchema.ScenarioEventType[] diff --git a/common/template-parser.ts b/common/template-parser.ts index 33a04db6b..49fe65d85 100644 --- a/common/template-parser.ts +++ b/common/template-parser.ts @@ -806,10 +806,10 @@ function getEntities(holder: IterableHolder, opts: TemplateOpts) { if (char.deletedAt) continue // Exclude temp characters that have been disabled/removed - if (char._id.startsWith('temp-') && char.favorite === false) continue + if (char._id?.startsWith('temp-') && char.favorite === false) continue // Exclude non-temp characters that have been removed from the chat - if (!char._id.startsWith('temp-') && !opts.chat?.characters?.[char._id]) continue + if (!char._id?.startsWith('temp-') && !opts.chat?.characters?.[char._id]) continue chars.push(char) } return chars @@ -951,7 +951,7 @@ function getPlaceholder( ) { if (opts.repeatable && !repeatableHolders.has(node.value as any)) return '' - if (node.value.startsWith('json.')) { + if (node.value?.startsWith('json.')) { const target = node.value.replace('json.', '') const jsonValues = opts.jsonValues || opts.history?.slice(-1)[0]?.json || {} @@ -960,7 +960,7 @@ function getPlaceholder( return value } - if (node.value.startsWith('var.') || node.value.startsWith('vars.')) { + if (node.value?.startsWith('var.') || node.value?.startsWith('vars.')) { const name = node.value.replace('var.', '').replace('vars.', '') return opts.parts?.props?.[name] || '' } diff --git a/common/util.ts b/common/util.ts index fcab126ff..9fea0c875 100644 --- a/common/util.ts +++ b/common/util.ts @@ -33,7 +33,7 @@ export function stopResponse(opts: { text: string; author: string; stops: string // this is an edited and inverted ver of https://stackoverflow.com/a/70385497 export function incompleteJson(data: string) { - if (data.startsWith('{') && !data.endsWith('}')) return true + if (data?.startsWith('{') && !data.endsWith('}')) return true try { const parsed = JSON.parse(data) if (parsed && typeof parsed === 'object') { @@ -78,7 +78,7 @@ export function parseEvent(msg: string) { export function getMimeTypeBase64(base64: string) { const [start, encode] = base64.split(';') - if (!start.startsWith('data:')) return { mimeType: 'image/jpeg', data: base64 } + if (!start?.startsWith('data:')) return { mimeType: 'image/jpeg', data: base64 } return { mimeType: start.slice(5), data: encode.replace('base64,', '') } } @@ -220,7 +220,7 @@ export function toDuration(valueSecs: number, full?: boolean) { if (full) { return [`${days}d`, `${hours}h`, `${minutes}m`, `${seconds}s`] - .filter((time) => !time.startsWith('0')) + .filter((time) => !time?.startsWith('0')) .join(':') } @@ -419,7 +419,7 @@ export function getBotName( const charId = msg.characterId || '' if (!charId) return replyAs?.name || main.name - if (charId.startsWith('temp-')) { + if (charId?.startsWith('temp-')) { const temp = chat.tempCharacters?.[charId] if (!temp) return main.name return temp.name diff --git a/srv/adapter/chat-completion.ts b/srv/adapter/chat-completion.ts index 17a255bea..2a165d422 100644 --- a/srv/adapter/chat-completion.ts +++ b/srv/adapter/chat-completion.ts @@ -47,9 +47,9 @@ export async function splitSampleChat(opts: SplitSampleChatProps, counter: Token } const sample = trimmed.toLowerCase().startsWith('system:') ? trimmed.slice(7).trim() : trimmed - const role = sample.startsWith(char + ':') + const role = sample?.startsWith(char + ':') ? 'assistant' - : sample.startsWith(sender + ':') + : sample?.startsWith(sender + ':') ? 'user' : 'system' diff --git a/srv/adapter/claude.ts b/srv/adapter/claude.ts index 9f5332186..de553580e 100644 --- a/srv/adapter/claude.ts +++ b/srv/adapter/claude.ts @@ -577,11 +577,11 @@ async function createClaudePrompt(opts: AdapterProps) { for (const line of all) { const distanceFromBottom = all.length - 1 - i - const lineType: LineType = line.startsWith(sender) + const lineType: LineType = line?.startsWith(sender) ? 'user' - : line.startsWith('System:') + : line?.startsWith('System:') ? 'system' - : line.startsWith(sampleAmble) + : line?.startsWith(sampleAmble) ? 'example' : 'char' if (distanceFromBottom === examplePos) { diff --git a/srv/adapter/kobold.ts b/srv/adapter/kobold.ts index 2b8c2428f..6f83466f2 100644 --- a/srv/adapter/kobold.ts +++ b/srv/adapter/kobold.ts @@ -472,10 +472,10 @@ async function validateModel(opts: AdapterProps, baseURL: string, payload: any, if (low.includes(':')) { const [name] = m.name.split(':') if (name === paylow) return true - if (name.startsWith(paylow)) return true + if (name?.startsWith(paylow)) return true } - return low.startsWith(paylow) + return low?.startsWith(paylow) }) if (!match) { payload.model = models[0].name diff --git a/srv/adapter/petals.ts b/srv/adapter/petals.ts index 7b54e719f..a7719edb8 100644 --- a/srv/adapter/petals.ts +++ b/srv/adapter/petals.ts @@ -71,7 +71,7 @@ export const handlePetals: ModelAdapter = async function* (opts) { function generateStream(url: string, model: string, opts: AdapterProps, body: PetalRequest) { url = url.toLocaleLowerCase().replace('https:', 'wss:').replace('http:', 'ws:') - if (!url.startsWith('ws')) { + if (!url?.startsWith('ws')) { url = `ws://${url}` } diff --git a/srv/api/classify/index.ts b/srv/api/classify/index.ts index 35c291105..bb28a6bbf 100644 --- a/srv/api/classify/index.ts +++ b/srv/api/classify/index.ts @@ -17,6 +17,6 @@ router.get( export default router function url(path: string) { - path = path.startsWith('/') ? path : `/${path}` + path = path?.startsWith('/') ? path : `/${path}` return `${config.classifyUrl}${path}` } diff --git a/srv/api/upload.ts b/srv/api/upload.ts index 69aebfd25..5c7f3c8ba 100644 --- a/srv/api/upload.ts +++ b/srv/api/upload.ts @@ -210,7 +210,7 @@ function isAllowedType(contentType: string) { */ function safeRelativeResolve(root: string, filename: string) { const resolved_path = normalize(resolve(root, filename)) - if (!resolved_path.startsWith(root)) { + if (!resolved_path?.startsWith(root)) { throw errors.BadRequest } return resolved_path diff --git a/srv/app.ts b/srv/app.ts index f346b7d51..1f3632eed 100644 --- a/srv/app.ts +++ b/srv/app.ts @@ -97,7 +97,7 @@ export function createApp() { } app.use((req, res, next) => { - if (req.url.startsWith('/api') || req.url.startsWith('/v1')) { + if (req.url?.startsWith('/api') || req.url.startsWith('/v1')) { return next(errors.NotFound) } diff --git a/srv/db/user.ts b/srv/db/user.ts index 5c058b9c0..62599fad6 100644 --- a/srv/db/user.ts +++ b/srv/db/user.ts @@ -623,7 +623,7 @@ export function toSafeUser(user: AppSchema.User, seed?: string) { if (user.providers) { for (const prov of user.providers) { - if (prov.provider.startsWith('self-') && prov.key) { + if (prov.provider?.startsWith('self-') && prov.key) { prov.userKey = decryptText(prov.key, true) } diff --git a/srv/image/index.ts b/srv/image/index.ts index 18f12fb46..4ef314460 100644 --- a/srv/image/index.ts +++ b/srv/image/index.ts @@ -106,7 +106,7 @@ export async function generateImage(opts: ImageGenerateRequest, log: AppLog, gue if (image) { // Guest images do not get saved under any circumstances - if (typeof image.content === 'string' && image.content.startsWith('http')) { + if (typeof image.content === 'string' && image.content?.startsWith('http')) { output = image.content } diff --git a/web/pages/Character/util.ts b/web/pages/Character/util.ts index f8a3444a7..f0ada7991 100644 --- a/web/pages/Character/util.ts +++ b/web/pages/Character/util.ts @@ -250,7 +250,7 @@ export function charToJson(char: AppSchema.Character, format: string) { } function getExt(url: string): { type: 'base64' | 'url'; ext: string } { - if (url.startsWith('data:')) { + if (url?.startsWith('data:')) { const [header] = url.split(',') const ext = header.slice(11, -7) return imageApi.ALLOWED_TYPES.has(ext) diff --git a/web/pages/Chat/MemberModal.tsx b/web/pages/Chat/MemberModal.tsx index 47244d3c9..ef64f4b33 100644 --- a/web/pages/Chat/MemberModal.tsx +++ b/web/pages/Chat/MemberModal.tsx @@ -400,7 +400,7 @@ const CharacterParticipant: Component<{ chat: AppSchema.Chat | undefined edit?: (charId: string) => void }> = (props) => { - const isTemp = createMemo(() => props.char._id.startsWith('temp-')) + const isTemp = createMemo(() => props.char._id?.startsWith('temp-')) const toggleTempChar = (state: boolean) => { if (!props.chat) return @@ -440,7 +440,7 @@ const CharacterParticipant: Component<{
{props.isMain ? 'Main Character' - : props.char._id.startsWith('temp-') + : props.char._id?.startsWith('temp-') ? 'Temporary Character' : 'Character'}
diff --git a/web/pages/Chat/util.ts b/web/pages/Chat/util.ts index ec9190ec3..1b16e277e 100644 --- a/web/pages/Chat/util.ts +++ b/web/pages/Chat/util.ts @@ -182,7 +182,7 @@ export function useEditableBots() { for (const bot of chats.activeBots) { if (bot.deletedAt) continue - if (bot._id.startsWith('temp-') && bot.favorite === false) continue + if (bot._id?.startsWith('temp-') && bot.favorite === false) continue ids.set(bot._id, bot) } @@ -230,7 +230,7 @@ export function canConvertGaslightV2(preset: Partial) { } function tempSort(a: AppSchema.Character, b: AppSchema.Character) { - return +!b._id.startsWith('temp-') - +!a._id.startsWith('temp-') || a.name.localeCompare(b.name) + return +!b._id?.startsWith('temp-') - +!a._id?.startsWith('temp-') || a.name.localeCompare(b.name) } export function isEligible() { diff --git a/web/shared/util.ts b/web/shared/util.ts index cb31cdcbb..c8561a10e 100644 --- a/web/shared/util.ts +++ b/web/shared/util.ts @@ -182,7 +182,7 @@ export function tryParseImport(content: string) { return json } catch (ex) {} - if (content.startsWith('data:')) { + if (content?.startsWith('data:')) { try { const index = content.indexOf(',') const data = decodeURIComponent(content.slice(index + 1)) @@ -358,8 +358,8 @@ export function getAssetPrefix() { } export function isBase64(file: string) { - if (file.startsWith('/') || file.startsWith('http')) return false - if (file.startsWith('data:')) return true + if (file?.startsWith('/') || file?.startsWith('http')) return false + if (file?.startsWith('data:')) return true return file.length > 500 } diff --git a/web/store/data/bot-generate.ts b/web/store/data/bot-generate.ts index aaf0768d2..03f11ddb5 100644 --- a/web/store/data/bot-generate.ts +++ b/web/store/data/bot-generate.ts @@ -540,7 +540,7 @@ async function handlePostStreamResponse(input: { if (canCreate) { opts.state = 'complete' await msgsApi.createMessage({ - kind: opts.kind.startsWith('send-event') ? opts.kind : 'send-noreply', + kind: opts.kind?.startsWith('send-event') ? opts.kind : 'send-noreply', chatId, messageId: messageId, // Consider removing deterministic ID creation text: response, @@ -993,13 +993,13 @@ async function getGenerateProps(opts: GenerateOpts, active: ChatDetail) { } const getBot = (id: string) => { - if (!isLoggedIn() && !id.startsWith('temp-')) { + if (!isLoggedIn() && !id?.startsWith('temp-')) { const { characters } = getStore('character').getState() const char = characters.list.find((ch) => ch._id === id) if (char) return char } - if (id.startsWith('temp-')) return entities.chat.tempCharacters?.[id]! + if (id?.startsWith('temp-')) return entities.chat.tempCharacters?.[id]! const { chatChars } = getStore('character').getState() const fullChar = chatChars.map[id] diff --git a/web/store/event.ts b/web/store/event.ts index b87ef42a5..54969eca4 100644 --- a/web/store/event.ts +++ b/web/store/event.ts @@ -223,8 +223,8 @@ function executeEvent(chat: AppSchema.Chat, event: AppSchema.ScenarioEvent) { function updateChatScenarioStates(chat: AppSchema.Chat, assigns: string[]) { const chatStates = chat.scenarioStates || [] if (assigns.length) { - const add = assigns.filter((s) => !s.startsWith('!')) - const remove = assigns.filter((s) => s.startsWith('!')).map((s) => s.slice(1)) + const add = assigns.filter((s) => !s?.startsWith('!')) + const remove = assigns.filter((s) => s?.startsWith('!')).map((s) => s.slice(1)) const updatedStates = Array.from( new Set(chatStates.filter((s) => !remove.includes(s)).concat(add)) ) diff --git a/web/store/message.ts b/web/store/message.ts index 84d46cfe9..3342e504d 100644 --- a/web/store/message.ts +++ b/web/store/message.ts @@ -581,10 +581,10 @@ async function handleImage(body: { const { msgs, imagesSaved, activeChatId } = msgStore.getState() const isImageUrl = - image.startsWith('/asset') || - image.startsWith('asset/') || - image.endsWith('png') || - image.endsWith('jpg') || + image?.startsWith('/asset') || + image?.startsWith('asset/') || + image?.endsWith('png') || + image?.endsWith('jpg') || image.endsWith('jpeg') if (!imagesSaved && isImageUrl) { @@ -596,7 +596,7 @@ async function handleImage(body: { } if (!isImageUrl) { - image = image.startsWith('data') ? image : `data:image/png;base64,${image}` + image = image?.startsWith('data') ? image : `data:image/png;base64,${image}` } const cacheId = imagesSaved ? '' : `cache:${requestId}` diff --git a/web/store/response.ts b/web/store/response.ts index 58509edc2..8f5a754c4 100644 --- a/web/store/response.ts +++ b/web/store/response.ts @@ -480,7 +480,7 @@ async function handlePreSend(opts: { onSuccess?: () => void onError?: (err?: string) => void }) { - const isEvent = opts.mode.startsWith('send-event:') + const isEvent = opts.mode?.startsWith('send-event:') if (!isEvent && opts.mode !== 'ooc' && opts.mode !== 'send' && opts.mode !== 'send-noreply') { return }