chatCompletions.ts
1 import { useMessageStore } from '@/renderer/store/message' 2 import { useSnackbarStore } from '@/renderer/store/snackbar' 3 import { useChatbotStore } from '@/renderer/store/chatbot' 4 import { useAgentStore } from '@/renderer/store/agent' 5 import { useMcpStore } from '@/renderer/store/mcp' 6 import { jwtDecode } from 'jwt-decode' 7 import { getApiToken } from '@/renderer/utils' 8 import type { ChatbotConfig } from '@/types/llm' 9 10 import { SamplingRequestParams, SamplingMessage } from '@/types/ipc' 11 12 import type { SessionEntry, McpToolMessage } from '@/renderer/types/session' 13 14 import { unionBy } from 'lodash' 15 16 import type { 17 AssistantMessage, 18 ToolCall, 19 ChatCompletionRequestMessage, 20 ChatCompletionMessage 21 } from '@/renderer/types/message' 22 23 import { ReasoningEffort, REASONING_EFFORT, ENABLE_THINKING } from '@/renderer/types' 24 25 type ChatRequestBody = { 26 model?: string 27 stream?: boolean 28 temperature?: number 29 messages?: ChatCompletionMessage[] 30 top_p?: number 31 tools?: McpToolMessage[] 32 reasoning_effort?: ReasoningEffort 33 enable_thinking?: boolean 34 chat_template_kwargs?: Record<string, any> 35 [key: string]: unknown 36 } 37 38 type SimpleStreamChoice = { 39 index: number 40 delta: AssistantMessage 41 message?: AssistantMessage 42 finish_reason: string | null 43 } 44 45 type SimpleStreamResponse = { 46 choices: SimpleStreamChoice[] 47 } 48 49 type SimpleCfResponse = { 50 response: AssistantMessage 51 } 52 53 export type ChatProcessResult = 'aborted' | 'error' | 'done' 54 55 const THINK_OPEN = '<think>' 56 const THINK_CLOSE = '</think>' 57 58 const isObjectEmpty = (obj?: Record<string, unknown>): boolean => { 59 return !!obj && Object.keys(obj).length === 0 60 } 61 62 export const isEmptyTools = (tools: any): boolean => { 63 if (!tools) { 64 return true 65 } else if (Array.isArray(tools)) { 66 if (tools.length === 0) { 67 return true 68 } else { 69 return isObjectEmpty(tools[0]) 70 } 71 } else { 72 return true 73 } 74 } 75 76 async function updateToken(cli: string): Promise<string | undefined> { 77 try { 78 return await getApiToken(cli) 79 } catch { 80 const snackbarStore = useSnackbarStore() 81 snackbarStore.showWarningMessage('chat.token-fail') 82 return 83 } 84 } 85 86 async function checkTokenUpdate(chatbotConfig: ChatbotConfig): Promise<string | undefined> { 87 if (chatbotConfig.apiCli) { 88 // Might be a dynamic JWT token, check the expiration 89 try { 90 if (!chatbotConfig.apiKey) { 91 const snackbarStore = useSnackbarStore() 92 snackbarStore.showInfoMessage('chat.token-refresh') 93 return await updateToken(chatbotConfig.apiCli) 94 } 95 96 const payload = jwtDecode(chatbotConfig.apiKey) 97 if (!payload.exp) { 98 // Never expired 99 return 100 } 101 102 const currentTime = Math.floor(Date.now() / 1000) 103 const remaining = payload.exp - currentTime 104 105 // Update if remaining time < 1 sec 106 if (remaining <= 1) { 107 const snackbarStore = useSnackbarStore() 108 snackbarStore.showInfoMessage('chat.token-refresh') 109 return await updateToken(chatbotConfig.apiCli) 110 } else { 111 const readableExp = new Date(payload.exp * 1000) 112 console.log(`Token valid until: ${readableExp}`) 113 } 114 } catch { 115 // Crashed, not a JWT token or update failed 116 } 117 } 118 return 119 } 120 121 const promptMessage = ( 122 conversation: ChatCompletionRequestMessage[], 123 systemPrompt: string | undefined 124 ): ChatCompletionMessage[] => { 125 if (systemPrompt) { 126 return [{ content: systemPrompt, role: 'system' }, ...conversation] 127 } else { 128 return [...conversation] 129 } 130 } 131 132 export const createCompletion = async ( 133 session: SessionEntry, 134 sampling?: SamplingRequestParams 135 ): Promise<ChatProcessResult> => { 136 const snackbarStore = useSnackbarStore() 137 138 const messageStore = useMessageStore() 139 140 const mcpStore = useMcpStore() 141 const agentStore = useAgentStore() 142 143 const chatbotStore = useChatbotStore() 144 145 const chatbotConfig = chatbotStore.chatbots[chatbotStore.selectedChatbotId] 146 147 console.log(chatbotConfig) 148 149 try { 150 // Create a completion (axios is not used here because it does not support streaming) 151 152 const headers: HeadersInit = { 153 'Content-Type': chatbotConfig.contentType 154 } 155 156 const newApiKey = await checkTokenUpdate(chatbotConfig) 157 if (newApiKey) { 158 chatbotStore.batchChatbotApiKey(chatbotConfig.apiCli, newApiKey) 159 } 160 161 if (chatbotConfig.apiKey) { 162 if (chatbotConfig.authorization) { 163 headers.Authorization = `${chatbotConfig.authPrefix} ${chatbotConfig.apiKey}` 164 } else { 165 headers['x-api-key'] = chatbotConfig.apiKey 166 } 167 } 168 169 const extraBody = chatbotConfig.enableExtraBody ? chatbotConfig.extraBody : {} 170 171 const body: ChatRequestBody = { 172 model: chatbotConfig.model, 173 stream: chatbotConfig.stream, 174 ...extraBody 175 } 176 177 console.log(body) 178 179 if (typeof chatbotConfig.reasoningEffort === 'number') { 180 body['reasoning_effort'] = REASONING_EFFORT[chatbotConfig.reasoningEffort] 181 } 182 183 if (typeof chatbotConfig.enableThinking === 'number') { 184 if (ENABLE_THINKING[chatbotConfig.enableThinking] === 'true') { 185 body['chat_template_kwargs'] = { 186 enable_thinking: true, 187 thinking: true 188 } 189 body['enable_thinking'] = true 190 } else if (ENABLE_THINKING[chatbotConfig.enableThinking] === 'false') { 191 body['chat_template_kwargs'] = { 192 enable_thinking: false, 193 thinking: false 194 } 195 body['enable_thinking'] = false 196 } 197 } 198 199 const target = session.messages 200 201 if (!sampling) { 202 const conversation = target.reduce( 203 (newConversation: ChatCompletionRequestMessage[], item) => { 204 if (item.role === 'assistant') { 205 const { reasoning_content, ...rest } = item 206 void reasoning_content 207 newConversation.push(rest) 208 } 209 // (item.role === "user" && item.content[0].type === "image_url") { 210 // // Image is too large, only latest query could be kept 211 // newConversation = [item]; 212 // } 213 else { 214 newConversation.push(item) 215 } 216 return newConversation 217 }, 218 [] 219 ) 220 221 body.messages = promptMessage(conversation, agentStore.getPrompt()) 222 223 if (chatbotConfig.maxTokensValue) { 224 body[chatbotConfig.maxTokensPrefix] = parseInt(chatbotConfig.maxTokensValue) 225 } 226 227 if (chatbotConfig.temperature) { 228 body.temperature = parseFloat(chatbotConfig.temperature) 229 } 230 231 if (chatbotConfig.topP) { 232 body.top_p = parseFloat(chatbotConfig.topP) 233 } 234 235 if (chatbotConfig.mcp) { 236 const tools = await agentStore.getTools() 237 if (tools && tools.length > 0) { 238 body.tools = tools 239 session.tools = unionBy(session.tools, tools, 'function.name') 240 } 241 } 242 } else { 243 const msg: ChatCompletionRequestMessage[] = sampling.messages.map( 244 (item: SamplingMessage) => ({ 245 role: item.role, 246 content: [mcpStore.convertItem(item.content)] 247 }) 248 ) 249 body.messages = promptMessage(msg, sampling.systemPrompt) 250 body.temperature = sampling.temperature 251 if (sampling.maxTokens) { 252 body[chatbotConfig.maxTokensPrefix] = Number(sampling.maxTokens) 253 } 254 } 255 256 const request = { 257 headers, 258 method: chatbotConfig.method, 259 body: JSON.stringify(body) 260 } 261 262 const abortController = new AbortController() 263 264 console.log('Chat session started: ', session.id) 265 messageStore.generating[session.id] = abortController 266 267 const completion = await fetch( 268 chatbotConfig.url + (chatbotConfig.path ? chatbotConfig.path : ''), 269 { 270 ...request, 271 signal: abortController.signal 272 } 273 ) 274 275 console.log(completion) 276 277 // Handle errors 278 if (!completion.ok) { 279 let errMessage = `${completion.status}: ${completion.statusText} ${completion.url}` 280 try { 281 const errorData = await completion.json() 282 if (errorData.error?.message) { 283 errMessage = `${completion.status}: ${errorData.error.message}` 284 } else if (errorData.detail[0]?.msg) { 285 const loc = errorData.detail[0]?.loc ? ` - ${errorData.detail[0].loc}:` : ':' 286 errMessage = `${completion.status}${loc} ${errorData.detail[0].msg}` 287 } 288 } finally { 289 snackbarStore.showErrorMessage(errMessage) 290 return 'error' 291 } 292 } 293 294 if (completion.redirected) { 295 snackbarStore.showWarningMessage(`${completion.url}`) 296 } 297 298 // Create a reader 299 const reader = completion.body?.getReader() 300 if (!reader) { 301 snackbarStore.showErrorMessage('snackbar.parse-stream-fail') 302 return 'error' 303 } 304 305 // Add the bot message 306 target.push({ 307 content: '', 308 reasoning_content: '', 309 tool_calls: [], 310 role: 'assistant' 311 }) 312 313 // The type of lastItem is guaranteed to be AssistantMessage, 314 // which is the type of the object just pushed into the array 315 const lastItem = target.at(-1) as AssistantMessage 316 317 const buffer = '' 318 319 // Read the stream 320 await read(reader, session.id, lastItem, buffer, chatbotConfig.stream) 321 } catch (error: any) { 322 snackbarStore.showErrorMessage(error?.message) 323 } finally { 324 const result = messageStore.delete(session.id) ? 'done' : 'aborted' 325 return result 326 } 327 } 328 329 const read = async ( 330 reader: ReadableStreamDefaultReader<Uint8Array>, 331 sessionId: string, 332 target: AssistantMessage, 333 buffer: string, 334 stream: boolean 335 ): Promise<void> => { 336 // TextDecoder is a built-in object that allows you to convert a stream of bytes into a string 337 const decoder = new TextDecoder() 338 const messageStore = useMessageStore() 339 340 if (!(sessionId in messageStore.generating)) { 341 return reader.releaseLock() 342 } 343 // Destructure the value returned by reader.read() 344 const { done, value } = await reader.read() 345 346 // If the stream is done reading, release the lock on the reader 347 if (done) { 348 // if (sessionId in messageStore.generating) { 349 // messageStore.generating[sessionId].abort() 350 // delete messageStore.generating[sessionId] 351 // } 352 return reader.releaseLock() 353 } 354 355 // Convert the stream of bytes into a string 356 const chunks = decoder.decode(value) 357 358 if (stream) { 359 // Split stream 360 const parts = chunks.split('\n') 361 362 if (parts.length === 1) { 363 buffer += parts[0] 364 return read(reader, sessionId, target, buffer, stream) 365 } 366 367 if (buffer.length > 0) { 368 parts[0] = buffer + parts[0] 369 buffer = '' 370 } 371 372 const last = parts[parts.length - 1] 373 if (last && last.length > 0) { 374 buffer = parts.pop() ?? '' 375 } 376 377 parts 378 .map((line) => line.trim()) 379 .filter((line) => line.length > 0) 380 .forEach((line) => { 381 const pos = line.indexOf(':') 382 const name = line.substring(0, pos) 383 if (name !== 'data') { 384 return 385 } 386 const content = line.substring(pos + 1).trim() 387 if (content.length === 0) { 388 return 389 } else if (content === '[DONE]') { 390 return 391 } 392 parseJson(content, target) 393 }) 394 } else { 395 parseJson(chunks, target) 396 } 397 398 // Repeat the process 399 return read(reader, sessionId, target, buffer, stream) 400 } 401 402 const parseJson = (content: string, target: AssistantMessage) => { 403 try { 404 const parsed = JSON.parse(content as string) 405 parseChoices(parsed, target) 406 } catch (e) { 407 console.log(e, content) 408 parseChoice(content, target) 409 } 410 } 411 412 const parseChoices = ( 413 parsed: SimpleStreamResponse | SimpleCfResponse, 414 target: AssistantMessage 415 ) => { 416 if ('choices' in parsed) { 417 return parsed.choices.map((choice) => { 418 const content = choice.delta || choice.message 419 return parseChoice(content, target) 420 }) 421 } else if ('response' in parsed) { 422 return parseChoice(parsed.response, target) 423 } else { 424 return parseChoice(parsed, target) 425 } 426 } 427 428 const parseChoice = (choice: AssistantMessage | string, target: AssistantMessage) => { 429 if (choice) { 430 if (target.role === 'assistant') { 431 if (typeof choice === 'string') { 432 // target.content += choice 433 parseMixedContent(choice, target) 434 } else { 435 if (typeof choice.content === 'string') { 436 // target.content += choice.content 437 parseMixedContent(choice.content, target) 438 } 439 if (typeof choice.reasoning_content === 'string') { 440 target.reasoning_content += choice.reasoning_content 441 } 442 parseTool(choice.tool_calls, target) 443 } 444 } 445 } 446 } 447 448 const parseMixedContent = (chunk: string, target: AssistantMessage) => { 449 // Only cases with a single occurrence of THINK_CLOSE in the response are considered, as the model typically fails to function properly when multiple THINK_CLOSE instances appear. 450 451 const data = (target.content ?? '') + chunk 452 const closeIdx = data.indexOf(THINK_CLOSE) 453 if (closeIdx === -1) { 454 target.content += chunk 455 } else { 456 const openIdx = data.indexOf(THINK_OPEN) 457 const divider = closeIdx + THINK_CLOSE.length 458 const afterThink = data.substring(divider) 459 460 const startIdx = openIdx < 0 ? 0 : openIdx + THINK_OPEN.length 461 target.reasoning_content += data.substring(startIdx, closeIdx).trim() 462 target.content = afterThink.trimStart() 463 } 464 } 465 466 const parseTool = (tools: ToolCall[] | undefined, target: AssistantMessage) => { 467 // Early return if no tools to process 468 if (!tools) return 469 470 // Initialize tool_calls array if it doesn't exist 471 if (!target.tool_calls) { 472 target.tool_calls = [] 473 } 474 475 tools.forEach((tool) => { 476 const toolCalls = target.tool_calls! 477 const lastTool = toolCalls[toolCalls.length - 1] 478 const sourceFunc = tool.function 479 480 // Case 1: Merge with last tool call when: 481 // - There is a previous tool call AND 482 // - (Current tool has no ID OR IDs match) 483 if (lastTool && (!tool.id || lastTool.id === tool.id)) { 484 const targetFunc = lastTool.function 485 486 // Merge each property from source function 487 Object.keys(sourceFunc).forEach((key) => { 488 const typedKey = key as 'name' | 'arguments' 489 const value = sourceFunc[typedKey] 490 491 // Skip null values (don't overwrite existing values with null) 492 if (value === null) return 493 494 // Merge strategy: 495 // - If target has existing non-empty value: concatenate 496 // - Otherwise: overwrite 497 if (targetFunc[typedKey] && targetFunc[typedKey] !== '{}') { 498 targetFunc[typedKey] += value 499 } else { 500 targetFunc[typedKey] = value 501 } 502 }) 503 } 504 // Case 2: Add as new tool call 505 else { 506 // Ensure arguments has a default empty object if not provided 507 if (sourceFunc.arguments == null) { 508 sourceFunc.arguments = '{}' 509 } 510 toolCalls.push(tool) 511 } 512 }) 513 }