import { type LanguageModel, type ModelMessage, streamText, stepCountIs } from "ai"; import fs from "node:fs"; import path from "node:path"; import { getSessionByIdAndUser, getMessagesBySession, getMaxSortOrder, saveMessage, truncateMessagesAfter, deleteMessage, getMessageById, updateMessageContent, updateMessageParts, touchSession, countAssistantMessages, updateSession, } from "#server/service/agent/session"; import { getAgentToolsForChatByAgentId, getAgentToolsBySlugs, executeAgentTool } from "#server/service/agent-tool"; import { getMemoryContext } from "#server/service/agent-document"; import { checkRateLimit, incrementRateLimit } from "#server/service/agent/rate-limit"; import { resolveModelForUser, resolveModelAny, toLanguageModel } from "#server/service/llm/model-resolver"; import { generateSessionTitle } from "./title"; import type { AgentRow } from "./agent"; import type { AgentSessionRow, ContentPart } from "./types"; import type { StoredPart } from "#server/service/agent/stored-part"; import { extractTextFromParts, normalizeToParts } from "./types"; import { createStreamBuffer, appendChunk, markBufferDone, removeStreamBuffer } from "#server/service/agent/stream-buffer"; import { registerAbortController, unregisterAbortController } from "#server/service/agent/abort-manager"; import log4js from "logger"; const logger = log4js.getLogger("APP"); export interface ChatEngineParams { agent: AgentRow; session: AgentSessionRow; user: { id: number; role: string | null } | null; tempToken: string | null; body: { parts?: ContentPart[]; content?: string; editMessageId?: string; regenerate?: boolean; continueAfterApproval?: boolean; approvalToolCallId?: string; approved?: boolean; approvalReason?: string; enableThinking?: boolean; enableTools?: boolean; }; ip: string; preferredModelId?: number | null; globalDefaultModelId?: number | null; reviewApprovalMode?: string; requestId?: string; registerAbort: (sessionId: string, controller: AbortController) => void; unregisterAbort: (sessionId: string) => void; setRateLimitHeaders?: (headers: Record) => void; onClientClose?: (cb: () => void) => void; } export interface ChatEngineResult { response: Response; userMessageId: string | null; } function buildModelMessages( historyMessages: { id: string; role: string; content: string; parts: string | null }[], opts: { skipMessageId?: string; currentUserParts?: ContentPart[]; approvalToolResults?: Record; }, ): ModelMessage[] { const messages: ModelMessage[] = []; for (const m of historyMessages) { if (opts.skipMessageId && m.id === opts.skipMessageId) continue; if (m.role === "assistant" && m.parts) { let parts: StoredPart[]; try { parts = JSON.parse(m.parts); } catch { parts = []; } const assistantContent: Array> = []; if (m.content) { assistantContent.push({ type: "text", text: m.content }); } for (const p of parts) { if (p.type === "tool-call" && p.toolCallId && p.toolName) { if (p.state === "approval-requested" && p.approvalId) { assistantContent.push({ type: "tool-approval-request", approvalId: p.approvalId, toolCallId: p.toolCallId, }); } else if (p.state === "approval-responded" && p.approvalId) { const hasResult = opts.approvalToolResults?.[p.toolCallId] !== undefined; if (p.approved === true && hasResult) { assistantContent.push({ type: "tool-call", toolCallId: p.toolCallId, toolName: p.toolName, input: p.args ?? {}, }); } else { assistantContent.push({ type: "tool-call", toolCallId: p.toolCallId, toolName: p.toolName, input: p.args ?? {}, }); assistantContent.push({ type: "tool-approval-request", approvalId: p.approvalId, toolCallId: p.toolCallId, }); } } else { const toolCallPart: Record = { type: "tool-call", toolCallId: p.toolCallId, toolName: p.toolName, input: p.args ?? {}, }; if (p.state === "result" && p.result !== undefined) { toolCallPart.output = p.result; } assistantContent.push(toolCallPart); } } else if (p.type === "reasoning" && p.text) { assistantContent.push({ type: "reasoning", text: p.text }); } else if (p.type === "text" && p.text && !m.content) { assistantContent.push({ type: "text", text: p.text }); } } if (assistantContent.length > 0) { messages.push({ role: "assistant", content: assistantContent } as ModelMessage); } const toolResults: Array> = []; const approvalResponses: Array> = []; for (const p of parts) { if (p.type === "tool-call" && p.toolCallId && p.toolName && p.state === "result" && p.result !== undefined) { toolResults.push({ type: "tool-result", toolCallId: p.toolCallId, toolName: p.toolName, output: { type: "json" as const, value: p.result as unknown }, }); } if (p.type === "tool-call" && p.state === "approval-responded" && p.approvalId) { const tcId = p.toolCallId!; const hasResult = opts.approvalToolResults?.[tcId] !== undefined; if (p.approved === true && hasResult) { toolResults.push({ type: "tool-result", toolCallId: tcId, toolName: p.toolName!, output: { type: "json" as const, value: opts.approvalToolResults![tcId] as unknown }, }); } else { approvalResponses.push({ type: "tool-approval-response", approvalId: p.approvalId, approved: p.approved ?? false, reason: p.approvalReason, }); } } } if (toolResults.length > 0 || approvalResponses.length > 0) { messages.push({ role: "tool", content: [...toolResults, ...approvalResponses] } as ModelMessage); } } else if (m.role === "user") { const userContent = parseUserMessageContent(m.content, m.parts); if (userContent) { messages.push({ role: "user", content: userContent } as ModelMessage); } } } if (opts.currentUserParts !== undefined && opts.currentUserParts.length > 0) { const userContent = partsToModelContent(opts.currentUserParts); if (userContent) { messages.push({ role: "user", content: userContent } as ModelMessage); } } return messages; } function parseUserMessageContent(content: string, partsJson: string | null): string | Array> | undefined { if (partsJson) { try { const parsed: unknown = JSON.parse(partsJson); if (Array.isArray(parsed)) { const hasImage = parsed.some((p: any) => p && typeof p === "object" && p.type === "image"); if (hasImage) { return partsToModelContent(parsed as ContentPart[]); } } } catch { // fall through to text-only } } return content || undefined; } function resolveImageToDataContent(url: string): { data: string; mediaType: string } | null { if (/^https?:\/\//i.test(url) || /^data:/i.test(url)) { return null; } const filePath = url.replace(/^\/+/, ""); const absPath = path.resolve(process.cwd(), filePath); if (!fs.existsSync(absPath)) return null; const buffer = fs.readFileSync(absPath); const ext = path.extname(absPath).toLowerCase(); const mimeMap: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".svg": "image/svg+xml", ".ico": "image/x-icon", }; const mediaType = mimeMap[ext] || "application/octet-stream"; return { data: buffer.toString("base64"), mediaType }; } function partsToModelContent(parts: ContentPart[]): string | Array> | undefined { if (parts.length === 0) return undefined; const hasImage = parts.some((p) => p.type === "image"); if (!hasImage) { const text = extractTextFromParts(parts); return text || undefined; } const contentArr: Array> = []; for (const p of parts) { if (p.type === "text" && p.text) { contentArr.push({ type: "text", text: p.text }); } else if (p.type === "image" && p.image) { const local = resolveImageToDataContent(p.image); if (local) { contentArr.push({ type: "file", data: local.data, mediaType: p.mimeType || local.mediaType, }); } else if (/^https?:\/\//i.test(p.image)) { contentArr.push({ type: "file", data: new URL(p.image), mediaType: p.mimeType || "image/jpeg", }); } } } return contentArr.length > 0 ? contentArr : undefined; } export async function executeChat(params: ChatEngineParams): Promise { const { agent, session, user, body, ip, requestId = "-" } = params; const sessionId = session.id; const { parts: bodyParts, content: bodyContent, editMessageId, regenerate, continueAfterApproval, approvalToolCallId, approved, approvalReason, enableThinking: bodyEnableThinking, enableTools: bodyEnableTools, } = body; const isApprovalContinue = continueAfterApproval === true && approvalToolCallId; const inputParts: ContentPart[] = bodyParts ?? normalizeToParts(bodyContent); const textContent = extractTextFromParts(inputParts); const hasImages = inputParts.some((p) => p.type === "image"); const partsJson = hasImages ? JSON.stringify(inputParts) : null; if (!user) { const rateLimit = checkRateLimit(agent.slug, sessionId, ip); if (rateLimit.blocked) { params.setRateLimitHeaders?.({ "X-RateLimit-Session-Remaining": String(rateLimit.sessionRemaining), "X-RateLimit-Ip-Remaining": String(rateLimit.ipRemaining), }); throw createError({ statusCode: 429, statusMessage: rateLimit.sessionRemaining === 0 ? "当前会话已达回复上限,请登录后继续" : "今日回复次数已达上限,请登录或明日再试", }); } params.setRateLimitHeaders?.({ "X-RateLimit-Session-Remaining": String(rateLimit.sessionRemaining), "X-RateLimit-Ip-Remaining": String(rateLimit.ipRemaining), }); } let resolvedModel; if (user) { const targetModelId = session.modelId ?? params.preferredModelId ?? agent.defaultModelId ?? params.globalDefaultModelId; if (!targetModelId) { throw createError({ statusCode: 400, statusMessage: "未配置模型,请先在设置中选择模型" }); } resolvedModel = await resolveModelForUser(targetModelId, user.id); } else { const targetModelId = session.modelId ?? agent.defaultModelId ?? params.globalDefaultModelId; if (!targetModelId) { throw createError({ statusCode: 400, statusMessage: "系统未配置默认模型" }); } resolvedModel = await resolveModelAny(targetModelId); } if (!resolvedModel) { throw createError({ statusCode: 404, statusMessage: "模型不存在或无权访问" }); } if (!resolvedModel.provider.apiKey) { throw createError({ statusCode: 400, statusMessage: "供应商未配置 API Key" }); } const languageModel: LanguageModel = toLanguageModel(resolvedModel); const baseSystemPrompt = session.systemPrompt?.trim() || agent.systemPrompt || undefined; let systemPrompt = baseSystemPrompt; if (user) { const memoryContext = await getMemoryContext(user.id, agent.id); if (memoryContext) { const memoryInstruction = "\n\n# 持久记忆(已注入上下文)\n以下 标签内的信息是你的持久记忆,每次对话开始时自动加载,始终是最新的。\n**禁止调用 document 工具的 read 或 list 操作来读取这些记忆** — 它们已经在你的上下文中了。\n直接使用 中的内容回答用户问题即可。\n只有需要创建新记忆或修改已有记忆时,才使用 document 工具。"; systemPrompt = systemPrompt ? `${systemPrompt}${memoryInstruction}\n${memoryContext}` : `${memoryInstruction}\n${memoryContext}`; } } const sessionEnableTools = session.enableTools !== null ? session.enableTools === 1 : agent.enableTools === 1; const toolsEnabled = bodyEnableTools !== undefined ? bodyEnableTools : sessionEnableTools; const { tools } = await getAgentToolsForChatByAgentId({ agentId: agent.id, userId: user?.id ?? null, userRole: (user?.role as any) ?? null, enableTools: toolsEnabled, recursionDepth: 0, }); let historyMessages = await getMessagesBySession(sessionId, { limit: 100, latest: true }); let userMessage; let shouldGenerateTitle = false; if (isApprovalContinue) { userMessage = historyMessages.filter((m) => m.role === "user").pop() ?? historyMessages[0]; const assistantCount = await countAssistantMessages(sessionId); if (assistantCount <= 1) { shouldGenerateTitle = true; } } else if (editMessageId) { const editMsg = await getMessageById(editMessageId); if (!editMsg || editMsg.sessionId !== sessionId) { throw createError({ statusCode: 400, statusMessage: "编辑的消息不存在" }); } await truncateMessagesAfter(sessionId, editMsg.sortOrder); await updateMessageContent(editMessageId, textContent.trim()); if (partsJson) { await updateMessageParts(editMessageId, partsJson); } historyMessages = await getMessagesBySession(sessionId, { limit: 100, latest: true }); userMessage = editMsg; } else if (regenerate) { const lastAssistant = historyMessages.filter((m) => m.role === "assistant").pop(); if (lastAssistant) { await deleteMessage(lastAssistant.id); historyMessages = await getMessagesBySession(sessionId, { limit: 100, latest: true }); } const lastUser = historyMessages.filter((m) => m.role === "user").pop(); if (lastUser) { await updateMessageContent(lastUser.id, textContent.trim()); if (partsJson) { await updateMessageParts(lastUser.id, partsJson); } userMessage = lastUser; } else { const sortOrder = (await getMaxSortOrder(sessionId)) + 1; userMessage = await saveMessage({ sessionId, role: "user", content: textContent.trim(), parts: partsJson, sortOrder, }); shouldGenerateTitle = true; } } else { const sortOrder = (await getMaxSortOrder(sessionId)) + 1; userMessage = await saveMessage({ sessionId, role: "user", content: textContent.trim(), parts: partsJson, sortOrder, }); const assistantCount = await countAssistantMessages(sessionId); if (assistantCount === 0) { shouldGenerateTitle = true; } } const approvalToolResults: Record = {}; if (isApprovalContinue) { const pendingSlugs = new Set(); for (const m of historyMessages) { if (!m.parts) continue; let parts: StoredPart[]; try { parts = JSON.parse(m.parts); } catch { continue; } for (const p of parts) { if (p.type !== "tool-call" || !p.toolCallId || !p.toolName) continue; if (p.state !== "approval-responded" || p.approved !== true) continue; if (approvalToolResults[p.toolCallId] !== undefined) continue; pendingSlugs.add(p.toolName); } } const toolMap = await getAgentToolsBySlugs([...pendingSlugs]); for (const m of historyMessages) { if (!m.parts) continue; let parts: StoredPart[]; try { parts = JSON.parse(m.parts); } catch { continue; } for (const p of parts) { if (p.type !== "tool-call" || !p.toolCallId || !p.toolName) continue; if (p.state !== "approval-responded" || p.approved !== true) continue; if (approvalToolResults[p.toolCallId] !== undefined) continue; const agentTool = toolMap.get(p.toolName); if (!agentTool) { approvalToolResults[p.toolCallId] = { error: `Tool ${p.toolName} not found` }; continue; } try { const result = await executeAgentTool(agentTool.id, p.args, user?.id ?? null, p.approved, agent.id); approvalToolResults[p.toolCallId] = result; logger.info("[%s] [APPROVAL-EXEC] tool=%s result=%s", requestId, p.toolName, JSON.stringify(result).slice(0, 200)); } catch (err) { approvalToolResults[p.toolCallId] = { error: err instanceof Error ? err.message : String(err) }; logger.error("[%s] [APPROVAL-EXEC] tool=%s error=%s", requestId, p.toolName, approvalToolResults[p.toolCallId]); } } } } const modelMessages = buildModelMessages(historyMessages, { skipMessageId: isApprovalContinue ? undefined : userMessage?.id, currentUserParts: isApprovalContinue ? undefined : inputParts, approvalToolResults, }); const sessionEnableThinking = session.enableThinking !== null ? session.enableThinking === 1 : agent.enableThinking === 1; const thinkingEnabled = bodyEnableThinking !== undefined ? bodyEnableThinking : sessionEnableThinking; const maxSteps = agent.maxStepCount ?? 8; logger.info( "[%s] [CHAT-ENGINE] agent=%s userId=%s sessionId=%s thinking=%s tools=%d maxSteps=%d edit=%s regen=%s approval=%s", requestId, agent.slug, user?.id ?? "temp", sessionId, thinkingEnabled ? "on" : "off", Object.keys(tools).length, maxSteps, editMessageId ? "yes" : "no", regenerate ? "yes" : "no", isApprovalContinue ? "yes" : "no", ); const streamBuffer = createStreamBuffer(sessionId, { modelId: resolvedModel.dbId, userMessageId: userMessage?.id ?? null, }); const serverAbortController = new AbortController(); params.registerAbort(sessionId, serverAbortController); let streamedText = ""; let streamedReasoning = ""; let streamedToolCalls: Array> = []; let chunkPartId = 0; let streamedParts: Array> = []; let activeTextPartIdx = -1; let activeReasoningPartIdx = -1; params.onClientClose?.(() => { if (!serverAbortController.signal.aborted) { logger.info("[%s] [CHAT-ENGINE] client disconnected, aborting streamText", requestId); serverAbortController.abort(); } }); let approvalPrefixChunk: Uint8Array | null = null; if (isApprovalContinue && Object.keys(approvalToolResults).length > 0) { const prefixChunks = Object.entries(approvalToolResults).map( ([toolCallId, output]) => `data: ${JSON.stringify({ type: "tool-output-available", toolCallId, output, })}\n\n`, ); approvalPrefixChunk = new TextEncoder().encode(prefixChunks.join("")); appendChunk(sessionId, approvalPrefixChunk); logger.info("[%s] [APPROVAL-PREFIX] written %d bytes to buffer before stream start", requestId, approvalPrefixChunk.length); } const toolApproval = params.reviewApprovalMode === "all-pass" ? () => "approved" as const : undefined; const result = streamText({ model: languageModel, system: systemPrompt, messages: modelMessages, abortSignal: serverAbortController.signal, ...(Object.keys(tools).length > 0 ? { tools, stopWhen: stepCountIs(maxSteps), ...(toolApproval ? { toolApproval } : {}), } : {}), ...(thinkingEnabled ? { providerOptions: { openaiCompatible: { reasoningEffort: "high" }, }, } : {}), onError: (errorData) => { const err = errorData?.error; const errMsg = err instanceof Error ? err.message : String(err ?? "未知错误"); const errCause = err instanceof Error ? JSON.stringify(err.cause ?? null, null, 2) : ""; logger.error("[%s] [CHAT-ENGINE] streamText error: %s\ncause: %s", requestId, errMsg, errCause); }, onChunk: ({ chunk }) => { if (chunk.type === "text-delta") { streamedText += chunk.text; if (activeTextPartIdx < 0) { activeTextPartIdx = streamedParts.length; streamedParts.push({ id: `p_${chunkPartId++}`, type: "text", text: "" }); } const tp = streamedParts[activeTextPartIdx] as Record; tp.text = ((tp.text as string) || "") + chunk.text; } else if (chunk.type === "text-start") { activeTextPartIdx = streamedParts.length; streamedParts.push({ id: `p_${chunkPartId++}`, type: "text", text: "" }); } else if (chunk.type === "text-end") { activeTextPartIdx = -1; } else if (chunk.type === "reasoning-delta") { streamedReasoning += chunk.text; if (activeReasoningPartIdx < 0) { activeReasoningPartIdx = streamedParts.length; streamedParts.push({ id: `p_${chunkPartId++}`, type: "reasoning", text: "" }); } const rp = streamedParts[activeReasoningPartIdx] as Record; rp.text = ((rp.text as string) || "") + chunk.text; } else if (chunk.type === "reasoning-start") { activeReasoningPartIdx = streamedParts.length; streamedParts.push({ id: `p_${chunkPartId++}`, type: "reasoning", text: "" }); } else if (chunk.type === "reasoning-end") { activeReasoningPartIdx = -1; } else if (chunk.type === "tool-input-start") { streamedParts.push({ id: `p_${chunkPartId++}`, type: "tool-call", toolCallId: chunk.id, toolName: chunk.toolName, args: undefined, state: "preparing", }); } else if (chunk.type === "tool-call") { streamedToolCalls.push({ id: `p_${chunkPartId++}`, type: "tool-call", toolCallId: chunk.toolCallId, toolName: chunk.toolName, args: chunk.input, state: "call", }); const existing = streamedParts.find( (p) => p.type === "tool-call" && p.toolCallId === chunk.toolCallId, ); if (existing) { existing.args = chunk.input; existing.state = "call"; } else { streamedParts.push({ id: `p_${chunkPartId++}`, type: "tool-call", toolCallId: chunk.toolCallId, toolName: chunk.toolName, args: chunk.input, state: "call", }); } } else if (chunk.type === "tool-result") { const tcIdx = streamedToolCalls.findIndex((tc) => tc.toolCallId === chunk.toolCallId); const tcEntry = tcIdx >= 0 ? streamedToolCalls[tcIdx] : undefined; if (tcEntry) { tcEntry.result = chunk.output; tcEntry.state = "result"; } const part = streamedParts.find( (p) => p.type === "tool-call" && p.toolCallId === chunk.toolCallId, ); if (part) { part.result = chunk.output; part.state = "result"; } } }, onFinish: async ({ finishReason, usage, steps, text: assistantText }) => { try { if (serverAbortController.signal.aborted) { logger.info("[%s] [CHAT-ENGINE] onFinish skipped due to abort", requestId); params.unregisterAbort(sessionId); return; } logger.info( "[%s] [CHAT-ENGINE] finished: reason=%s steps=%d inputTokens=%d outputTokens=%d", requestId, finishReason, steps.length, usage?.inputTokens ?? 0, usage?.outputTokens ?? 0, ); const parts: Array> = []; let hasTextPart = false; for (const step of steps) { const toolResultMap = new Map(step.toolResults.map((tr) => [tr.toolCallId, tr.output])); const contentArr = step.content as Array>; logger.info( "[%s] [CHAT-ENGINE] step %d content order: %s", requestId, step.stepNumber, contentArr.map((cp) => cp.type + (cp.type === "tool-call" ? `(${(cp as any).toolName})` : "")).join(" → "), ); const approvalToolCallIds = new Set(); for (const cp of contentArr) { if (cp.type === "tool-approval-request" && (cp as any).approvalId && (cp as any).toolCall) { approvalToolCallIds.add((cp as any).toolCall.toolCallId); } } for (const cp of contentArr) { if (cp.type === "reasoning" && (cp as any).text) { parts.push({ id: `p_${parts.length}`, type: "reasoning", text: (cp as any).text }); } else if (cp.type === "text" && (cp as any).text) { parts.push({ id: `p_${parts.length}`, type: "text", text: (cp as any).text }); hasTextPart = true; } else if (cp.type === "tool-approval-request" && (cp as any).approvalId && (cp as any).toolCall) { const tc = (cp as any).toolCall; const toolResult = toolResultMap.get(tc.toolCallId); if (toolResult === undefined) { parts.push({ id: `p_${parts.length}`, type: "tool-call", toolCallId: tc.toolCallId, toolName: tc.toolName, args: tc.input, state: "approval-requested", approvalId: (cp as any).approvalId, }); } else { parts.push({ id: `p_${parts.length}`, type: "tool-call", toolCallId: tc.toolCallId, toolName: tc.toolName, args: tc.input, result: toolResult, state: "result", approvalId: (cp as any).approvalId, }); } } else if (cp.type === "tool-call") { const tc = cp as any; if (approvalToolCallIds.has(tc.toolCallId)) continue; const toolResult = toolResultMap.get(tc.toolCallId); parts.push({ id: `p_${parts.length}`, type: "tool-call", toolCallId: tc.toolCallId, toolName: tc.toolName, args: tc.input, result: toolResult, state: "result", }); } } } const hasToolCallPart = parts.some((p) => p.type === "tool-call"); const stepLimitReached = finishReason === "tool-calls" && !hasTextPart && hasToolCallPart; let finalText = assistantText; if (stepLimitReached) { const noticeText = "(已达到工具调用次数上限,模型未能生成最终回答。以上是工具调用的执行记录。)"; parts.push({ id: `p_${parts.length}`, type: "text", text: noticeText }); finalText = (finalText || "") + noticeText; logger.info("[%s] [CHAT-ENGINE] step limit reached, appended notice part", requestId); } if (isApprovalContinue) { const lastAssistant = historyMessages.filter((m) => m.role === "assistant").pop(); if (lastAssistant) { let existingParts: Array> = []; try { existingParts = lastAssistant.parts ? JSON.parse(lastAssistant.parts) : []; } catch { existingParts = []; } logger.info("[%s] [APPROVAL-CONTINUE] merged %d existing parts with %d new parts", requestId, existingParts.length, parts.length); for (const p of existingParts) { if (p.type === "tool-call" && p.state === "approval-responded") { const tcId = p.toolCallId as string | undefined; if (p.approved === true && tcId && approvalToolResults[tcId] !== undefined) { p.result = approvalToolResults[tcId]; p.state = "result"; } else if (p.approved === false) { p.result = "工具执行被拒绝"; p.state = "result"; } } } const newParts = parts.filter( (np) => np.type !== "tool-call" || !existingParts.some((ep) => ep.toolCallId === np.toolCallId), ); const idOffset = existingParts.length; for (let i = 0; i < newParts.length; i++) { newParts[i]!.id = `p_${idOffset + i}`; } const mergedParts = [...existingParts, ...newParts]; const mergedContent = (lastAssistant.content || "") + finalText; await updateMessageContent(lastAssistant.id, mergedContent); await updateMessageParts(lastAssistant.id, JSON.stringify(mergedParts)); } } else { const assistantSortOrder = (await getMaxSortOrder(sessionId)) + 1; await saveMessage({ sessionId, role: "assistant", content: finalText, parts: parts.length > 0 ? JSON.stringify(parts) : null, modelId: resolvedModel.dbId, inputTokens: usage?.inputTokens ?? null, outputTokens: usage?.outputTokens ?? null, sortOrder: assistantSortOrder, }); } await touchSession(sessionId); if (!user) { incrementRateLimit(agent.slug, sessionId, ip); const rateLimit = checkRateLimit(agent.slug, sessionId, ip); params.setRateLimitHeaders?.({ "X-RateLimit-Session-Remaining": String(rateLimit.sessionRemaining), "X-RateLimit-Ip-Remaining": String(rateLimit.ipRemaining), }); } if (shouldGenerateTitle && assistantText.trim().length > 0) { generateSessionTitle({ sessionId, agent, firstUserMessage: textContent.trim(), firstAssistantMessage: assistantText, userId: user?.id ?? null, }).catch((e) => { logger.error("[CHAT-ENGINE] title gen failed: %s", e?.message ?? e); }); } params.unregisterAbort(sessionId); } catch (e) { logger.error("[%s] [CHAT-ENGINE] onFinish error: %s", requestId, e instanceof Error ? e.message : String(e)); } }, onAbort: async () => { try { const partialText = streamedText; logger.info("[%s] [CHAT-ENGINE] aborted: streamedTextLen=%d streamedToolCalls=%d", requestId, partialText.length, streamedToolCalls.length); const parts: Array> = streamedParts .filter((p) => { if (p.type === "text") return ((p.text as string) || "").trim().length > 0; if (p.type === "reasoning") return ((p.text as string) || "").trim().length > 0; return true; }) .map((p, i) => ({ ...p, id: `p_${i}` })); let hasTextPart = parts.some((p) => p.type === "text"); const hasToolCallPart = parts.some((p) => p.type === "tool-call"); let finalText = partialText; if (!hasTextPart && hasToolCallPart) { const noticeText = "(对话已停止,以上是已完成的工具调用记录。)"; parts.push({ id: `p_${parts.length}`, type: "text", text: noticeText }); finalText = (finalText || "") + noticeText; hasTextPart = true; logger.info("[%s] [CHAT-ENGINE] aborted with tool calls but no text, appended notice", requestId); } if (isApprovalContinue) { const lastAssistant = historyMessages.filter((m) => m.role === "assistant").pop(); if (lastAssistant) { let existingParts: Array> = []; try { existingParts = lastAssistant.parts ? JSON.parse(lastAssistant.parts) : []; } catch { existingParts = []; } for (const p of existingParts) { if (p.type === "tool-call" && p.state === "approval-responded") { const tcId = p.toolCallId as string | undefined; if (p.approved === true && tcId && approvalToolResults[tcId] !== undefined) { p.result = approvalToolResults[tcId]; p.state = "result"; } else if (p.approved === false) { p.result = "工具执行被拒绝"; p.state = "result"; } } } const newParts = parts.filter( (np) => np.type !== "tool-call" || !existingParts.some((ep) => ep.toolCallId === np.toolCallId), ); const idOffset = existingParts.length; for (let i = 0; i < newParts.length; i++) { newParts[i]!.id = `p_${idOffset + i}`; } const mergedParts = [...existingParts, ...newParts]; const mergedContent = (lastAssistant.content || "") + finalText; await updateMessageContent(lastAssistant.id, mergedContent); await updateMessageParts(lastAssistant.id, JSON.stringify(mergedParts)); } } else { const hasContent = finalText.trim().length > 0 || parts.length > 0; if (hasContent) { const assistantSortOrder = (await getMaxSortOrder(sessionId)) + 1; await saveMessage({ sessionId, role: "assistant", content: finalText, parts: parts.length > 0 ? JSON.stringify(parts) : null, modelId: resolvedModel.dbId, sortOrder: assistantSortOrder, }); } } await touchSession(sessionId); if (!user) { incrementRateLimit(agent.slug, sessionId, ip); } markBufferDone(sessionId); params.unregisterAbort(sessionId); logger.info("[%s] [CHAT-ENGINE] abort save complete: contentLen=%d partsCount=%d", requestId, partialText.length, parts.length); } catch (abortErr) { logger.error("[%s] [CHAT-ENGINE] onAbort error: %s", requestId, abortErr instanceof Error ? abortErr.message : String(abortErr)); markBufferDone(sessionId); params.unregisterAbort(sessionId); } }, }); const responseHeaders: Record = { "X-User-Message-Id": userMessage?.id ?? "", }; const response = result.toUIMessageStreamResponse({ sendReasoning: true, headers: responseHeaders, onError: (error) => { const errMsg = error instanceof Error ? error.message : String(error ?? "未知错误"); logger.error("[%s] [CHAT-ENGINE] streamText error: %s", requestId, errMsg); return errMsg; }, }); function wrapStreamWithBuffer(originalBody: ReadableStream): ReadableStream { return new ReadableStream({ start(controller) { const reader = originalBody.getReader(); let clientDisconnected = false; function pump() { reader.read().then(({ done, value }) => { if (done) { markBufferDone(sessionId); if (!clientDisconnected) { try { controller.close(); } catch { /* already closed */ } } return; } if (value) { appendChunk(sessionId, value); if (!clientDisconnected) { try { controller.enqueue(value); } catch { clientDisconnected = true; } } } pump(); }).catch((err) => { if (serverAbortController.signal.aborted) { logger.info("[%s] [CHAT-ENGINE] stream reader aborted by server abort", requestId); } else { logger.error("[%s] [CHAT-ENGINE] buffer stream read error: %s", requestId, err instanceof Error ? err.message : String(err)); } markBufferDone(sessionId); if (!clientDisconnected) { try { controller.close(); } catch { /* already closed */ } } }); } pump(); }, }); } if (continueAfterApproval && approvalPrefixChunk && Object.keys(approvalToolResults).length > 0) { const originalBody = response.body; if (originalBody) { const prefix = approvalPrefixChunk; const transformed = new ReadableStream({ async start(controller) { controller.enqueue(prefix); const reader = originalBody.getReader(); let clientDisconnected = false; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { appendChunk(sessionId, value); if (!clientDisconnected) { try { controller.enqueue(value); } catch { clientDisconnected = true; } } } } } catch (err) { logger.error("[%s] [CHAT-ENGINE] stream read error: %s", requestId, err instanceof Error ? err.message : String(err)); const errorChunk = new TextEncoder().encode( `data: ${JSON.stringify({ type: "error", errorText: "流式响应中断" })}\n\n`, ); appendChunk(sessionId, errorChunk); if (!clientDisconnected) { try { controller.enqueue(errorChunk); } catch { /* client gone */ } } } finally { markBufferDone(sessionId); if (!clientDisconnected) { try { controller.close(); } catch { /* already closed */ } } } }, }); return { response: new Response(transformed, { status: response.status, statusText: response.statusText, headers: response.headers, }), userMessageId: userMessage?.id ?? null, }; } } if (response.body) { const bufferedBody = wrapStreamWithBuffer(response.body); return { response: new Response(bufferedBody, { status: response.status, statusText: response.statusText, headers: response.headers, }), userMessageId: userMessage?.id ?? null, }; } return { response, userMessageId: userMessage?.id ?? null, }; }