conversation-engine 保存用户消息时,复用桌面端 chat route 的存储格式:
- 将 base64 图片写入 .codepilot-uploads/ 目录
- 用 <!--files:[{id,name,type,size,filePath}]--> 格式嵌入消息 content
- 桌面 UI 的 MessageItem.parseMessageFiles() 可直接解析并通过
FileAttachmentDisplay + /api/uploads 渲染图片
将 getSession() 调用提前到 addMessage 之前,确保 workingDirectory 可用。
写入失败时降级为 [N image(s) attached] 文本指示。
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
/**
|
|
* Conversation Engine — processes inbound IM messages through Claude.
|
|
*
|
|
* Takes a ChannelBinding + inbound message, calls streamClaude(),
|
|
* consumes the SSE stream server-side, saves messages to DB,
|
|
* and returns the response text for delivery.
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import type { ChannelBinding } from './types';
|
|
import type { SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
|
|
import { streamClaude } from '../claude-client';
|
|
import {
|
|
addMessage,
|
|
getMessages,
|
|
acquireSessionLock,
|
|
renewSessionLock,
|
|
releaseSessionLock,
|
|
setSessionRuntimeStatus,
|
|
updateSdkSessionId,
|
|
updateSessionModel,
|
|
syncSdkTasks,
|
|
getSession,
|
|
getProvider,
|
|
getDefaultProviderId,
|
|
getSetting,
|
|
} from '../db';
|
|
import crypto from 'crypto';
|
|
|
|
export interface PermissionRequestInfo {
|
|
permissionRequestId: string;
|
|
toolName: string;
|
|
toolInput: Record<string, unknown>;
|
|
suggestions?: unknown[];
|
|
}
|
|
|
|
/**
|
|
* Callback invoked immediately when a permission_request SSE event arrives.
|
|
* This breaks the deadlock: the stream blocks until the permission is resolved,
|
|
* so we must forward the request to the IM *during* stream consumption,
|
|
* not after it returns.
|
|
*/
|
|
export type OnPermissionRequest = (perm: PermissionRequestInfo) => Promise<void>;
|
|
|
|
export interface ConversationResult {
|
|
responseText: string;
|
|
tokenUsage: TokenUsage | null;
|
|
hasError: boolean;
|
|
errorMessage: string;
|
|
/** Permission request events that were forwarded during streaming */
|
|
permissionRequests: PermissionRequestInfo[];
|
|
/** SDK session ID captured from status/result events, for session resume */
|
|
sdkSessionId: string | null;
|
|
}
|
|
|
|
/**
|
|
* Process an inbound message: send to Claude, consume the response stream,
|
|
* save to DB, and return the result.
|
|
*/
|
|
export async function processMessage(
|
|
binding: ChannelBinding,
|
|
text: string,
|
|
onPermissionRequest?: OnPermissionRequest,
|
|
abortSignal?: AbortSignal,
|
|
files?: FileAttachment[],
|
|
): Promise<ConversationResult> {
|
|
const sessionId = binding.codepilotSessionId;
|
|
|
|
// Acquire session lock
|
|
const lockId = crypto.randomBytes(8).toString('hex');
|
|
const lockAcquired = acquireSessionLock(sessionId, lockId, `bridge-${binding.channelType}`, 600);
|
|
if (!lockAcquired) {
|
|
return {
|
|
responseText: '',
|
|
tokenUsage: null,
|
|
hasError: true,
|
|
errorMessage: 'Session is busy processing another request',
|
|
permissionRequests: [],
|
|
sdkSessionId: null,
|
|
};
|
|
}
|
|
|
|
setSessionRuntimeStatus(sessionId, 'running');
|
|
|
|
// Lock renewal interval
|
|
const renewalInterval = setInterval(() => {
|
|
try { renewSessionLock(sessionId, lockId, 600); } catch { /* best effort */ }
|
|
}, 60_000);
|
|
|
|
try {
|
|
// Resolve session early — needed for workingDirectory and provider resolution
|
|
const session = getSession(sessionId);
|
|
|
|
// Save user message — persist file attachments to disk using the same
|
|
// <!--files:JSON--> format as the desktop chat route, so the UI can render them.
|
|
let savedContent = text;
|
|
if (files && files.length > 0) {
|
|
const workDir = binding.workingDirectory || session?.working_directory || '';
|
|
if (workDir) {
|
|
try {
|
|
const uploadDir = path.join(workDir, '.codepilot-uploads');
|
|
if (!fs.existsSync(uploadDir)) {
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
}
|
|
const fileMeta = files.map((f) => {
|
|
const safeName = path.basename(f.name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
const filePath = path.join(uploadDir, `${Date.now()}-${safeName}`);
|
|
const buffer = Buffer.from(f.data, 'base64');
|
|
fs.writeFileSync(filePath, buffer);
|
|
return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath };
|
|
});
|
|
savedContent = `<!--files:${JSON.stringify(fileMeta)}-->${text}`;
|
|
} catch (err) {
|
|
console.warn('[conversation-engine] Failed to persist file attachments:', err instanceof Error ? err.message : err);
|
|
savedContent = `[${files.length} image(s) attached] ${text}`;
|
|
}
|
|
} else {
|
|
savedContent = `[${files.length} image(s) attached] ${text}`;
|
|
}
|
|
}
|
|
addMessage(sessionId, 'user', savedContent);
|
|
|
|
// Resolve provider
|
|
let resolvedProvider: import('@/types').ApiProvider | undefined;
|
|
const providerId = session?.provider_id || '';
|
|
if (providerId && providerId !== 'env') {
|
|
resolvedProvider = getProvider(providerId);
|
|
}
|
|
if (!resolvedProvider) {
|
|
const defaultId = getDefaultProviderId();
|
|
if (defaultId) resolvedProvider = getProvider(defaultId);
|
|
}
|
|
|
|
// Effective model
|
|
const effectiveModel = binding.model || session?.model || getSetting('default_model') || undefined;
|
|
|
|
// Permission mode from binding mode
|
|
let permissionMode: string;
|
|
switch (binding.mode) {
|
|
case 'plan': permissionMode = 'plan'; break;
|
|
case 'ask': permissionMode = 'default'; break;
|
|
default: permissionMode = 'acceptEdits'; break;
|
|
}
|
|
|
|
// Load conversation history for context
|
|
const { messages: recentMsgs } = getMessages(sessionId, { limit: 50 });
|
|
const historyMsgs = recentMsgs.slice(0, -1).map(m => ({
|
|
role: m.role as 'user' | 'assistant',
|
|
content: m.content,
|
|
}));
|
|
|
|
const abortController = new AbortController();
|
|
if (abortSignal) {
|
|
if (abortSignal.aborted) {
|
|
abortController.abort();
|
|
} else {
|
|
abortSignal.addEventListener('abort', () => abortController.abort(), { once: true });
|
|
}
|
|
}
|
|
|
|
const stream = streamClaude({
|
|
prompt: text,
|
|
sessionId,
|
|
sdkSessionId: binding.sdkSessionId || undefined,
|
|
model: effectiveModel,
|
|
systemPrompt: session?.system_prompt || undefined,
|
|
workingDirectory: binding.workingDirectory || session?.working_directory || undefined,
|
|
abortController,
|
|
permissionMode,
|
|
provider: resolvedProvider,
|
|
conversationHistory: historyMsgs,
|
|
files,
|
|
onRuntimeStatusChange: (status: string) => {
|
|
try { setSessionRuntimeStatus(sessionId, status); } catch { /* best effort */ }
|
|
},
|
|
});
|
|
|
|
// Consume the stream server-side (replicate collectStreamResponse pattern).
|
|
// Permission requests are forwarded immediately via the callback during streaming
|
|
// because the stream blocks until permission is resolved — we can't wait until after.
|
|
return await consumeStream(stream, sessionId, onPermissionRequest);
|
|
} finally {
|
|
clearInterval(renewalInterval);
|
|
releaseSessionLock(sessionId, lockId);
|
|
setSessionRuntimeStatus(sessionId, 'idle');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Consume an SSE stream and extract response data.
|
|
* Mirrors the collectStreamResponse() logic from chat/route.ts.
|
|
*/
|
|
async function consumeStream(
|
|
stream: ReadableStream<string>,
|
|
sessionId: string,
|
|
onPermissionRequest?: OnPermissionRequest,
|
|
): Promise<ConversationResult> {
|
|
const reader = stream.getReader();
|
|
const contentBlocks: MessageContentBlock[] = [];
|
|
let currentText = '';
|
|
let tokenUsage: TokenUsage | null = null;
|
|
let hasError = false;
|
|
let errorMessage = '';
|
|
const seenToolResultIds = new Set<string>();
|
|
const permissionRequests: PermissionRequestInfo[] = [];
|
|
let capturedSdkSessionId: string | null = null;
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
const lines = value.split('\n');
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data: ')) continue;
|
|
|
|
let event: SSEEvent;
|
|
try {
|
|
event = JSON.parse(line.slice(6));
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
switch (event.type) {
|
|
case 'text':
|
|
currentText += event.data;
|
|
break;
|
|
|
|
case 'tool_use': {
|
|
if (currentText.trim()) {
|
|
contentBlocks.push({ type: 'text', text: currentText });
|
|
currentText = '';
|
|
}
|
|
try {
|
|
const toolData = JSON.parse(event.data);
|
|
contentBlocks.push({
|
|
type: 'tool_use',
|
|
id: toolData.id,
|
|
name: toolData.name,
|
|
input: toolData.input,
|
|
});
|
|
} catch { /* skip */ }
|
|
break;
|
|
}
|
|
|
|
case 'tool_result': {
|
|
try {
|
|
const resultData = JSON.parse(event.data);
|
|
const newBlock = {
|
|
type: 'tool_result' as const,
|
|
tool_use_id: resultData.tool_use_id,
|
|
content: resultData.content,
|
|
is_error: resultData.is_error || false,
|
|
};
|
|
if (seenToolResultIds.has(resultData.tool_use_id)) {
|
|
const idx = contentBlocks.findIndex(
|
|
(b) => b.type === 'tool_result' && 'tool_use_id' in b && b.tool_use_id === resultData.tool_use_id
|
|
);
|
|
if (idx >= 0) contentBlocks[idx] = newBlock;
|
|
} else {
|
|
seenToolResultIds.add(resultData.tool_use_id);
|
|
contentBlocks.push(newBlock);
|
|
}
|
|
} catch { /* skip */ }
|
|
break;
|
|
}
|
|
|
|
case 'permission_request': {
|
|
try {
|
|
const permData = JSON.parse(event.data);
|
|
const perm: PermissionRequestInfo = {
|
|
permissionRequestId: permData.permissionRequestId,
|
|
toolName: permData.toolName,
|
|
toolInput: permData.toolInput,
|
|
suggestions: permData.suggestions,
|
|
};
|
|
permissionRequests.push(perm);
|
|
// Forward immediately — the stream blocks until the permission is
|
|
// resolved, so we must send the IM prompt *now*, not after the stream ends.
|
|
if (onPermissionRequest) {
|
|
onPermissionRequest(perm).catch((err) => {
|
|
console.error('[conversation-engine] Failed to forward permission request:', err);
|
|
});
|
|
}
|
|
} catch { /* skip */ }
|
|
break;
|
|
}
|
|
|
|
case 'status': {
|
|
try {
|
|
const statusData = JSON.parse(event.data);
|
|
if (statusData.session_id) {
|
|
capturedSdkSessionId = statusData.session_id;
|
|
updateSdkSessionId(sessionId, statusData.session_id);
|
|
}
|
|
if (statusData.model) {
|
|
updateSessionModel(sessionId, statusData.model);
|
|
}
|
|
} catch { /* skip */ }
|
|
break;
|
|
}
|
|
|
|
case 'task_update': {
|
|
try {
|
|
const taskData = JSON.parse(event.data);
|
|
if (taskData.session_id && taskData.todos) {
|
|
syncSdkTasks(taskData.session_id, taskData.todos);
|
|
}
|
|
} catch { /* skip */ }
|
|
break;
|
|
}
|
|
|
|
case 'error':
|
|
hasError = true;
|
|
errorMessage = event.data || 'Unknown error';
|
|
break;
|
|
|
|
case 'result': {
|
|
try {
|
|
const resultData = JSON.parse(event.data);
|
|
if (resultData.usage) tokenUsage = resultData.usage;
|
|
if (resultData.is_error) hasError = true;
|
|
if (resultData.session_id) {
|
|
capturedSdkSessionId = resultData.session_id;
|
|
updateSdkSessionId(sessionId, resultData.session_id);
|
|
}
|
|
} catch { /* skip */ }
|
|
break;
|
|
}
|
|
|
|
// tool_output, tool_timeout, mode_changed, done — ignored for bridge
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flush remaining text
|
|
if (currentText.trim()) {
|
|
contentBlocks.push({ type: 'text', text: currentText });
|
|
}
|
|
|
|
// Save assistant message
|
|
if (contentBlocks.length > 0) {
|
|
const hasToolBlocks = contentBlocks.some(
|
|
(b) => b.type === 'tool_use' || b.type === 'tool_result'
|
|
);
|
|
const content = hasToolBlocks
|
|
? JSON.stringify(contentBlocks)
|
|
: contentBlocks
|
|
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
|
|
.map((b) => b.text)
|
|
.join('')
|
|
.trim();
|
|
|
|
if (content) {
|
|
addMessage(sessionId, 'assistant', content, tokenUsage ? JSON.stringify(tokenUsage) : null);
|
|
}
|
|
}
|
|
|
|
// Extract text-only response for IM delivery
|
|
const responseText = contentBlocks
|
|
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
|
|
.map((b) => b.text)
|
|
.join('')
|
|
.trim();
|
|
|
|
return {
|
|
responseText,
|
|
tokenUsage,
|
|
hasError,
|
|
errorMessage,
|
|
permissionRequests,
|
|
sdkSessionId: capturedSdkSessionId,
|
|
};
|
|
} catch (e) {
|
|
// Best-effort save on stream error
|
|
if (currentText.trim()) {
|
|
contentBlocks.push({ type: 'text', text: currentText });
|
|
}
|
|
if (contentBlocks.length > 0) {
|
|
const hasToolBlocks = contentBlocks.some(
|
|
(b) => b.type === 'tool_use' || b.type === 'tool_result'
|
|
);
|
|
const content = hasToolBlocks
|
|
? JSON.stringify(contentBlocks)
|
|
: contentBlocks
|
|
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
|
|
.map((b) => b.text)
|
|
.join('')
|
|
.trim();
|
|
if (content) {
|
|
addMessage(sessionId, 'assistant', content);
|
|
}
|
|
}
|
|
|
|
const isAbort = e instanceof DOMException && e.name === 'AbortError'
|
|
|| e instanceof Error && e.name === 'AbortError';
|
|
|
|
return {
|
|
responseText: '',
|
|
tokenUsage,
|
|
hasError: true,
|
|
errorMessage: isAbort ? 'Task stopped by user' : (e instanceof Error ? e.message : 'Stream consumption error'),
|
|
permissionRequests,
|
|
sdkSessionId: capturedSdkSessionId,
|
|
};
|
|
}
|
|
}
|