/** * Chat sessions & messages (MongoDB). Auth: Bearer JWT (same as skills publish). * Session _id is client-generated string (e.g. nanoid) for stable IDs across local/cloud. */ const { ObjectId } = require('mongodb') const AGENT_ONLY_BLOCK_TYPES = new Set([ 'tool_use', 'tool_result', 'thinking', 'redacted_thinking', 'server_tool_use', 'web_search_tool_result' ]) const MAX_AGENT_STATE_BYTES = 512 * 1024 const MAX_APPROVED_TOOL_NAMES = 300 const MAX_COMPLETED_SUBAGENTS = 120 const MAX_SUBAGENT_HISTORY = 240 const MAX_SUBAGENT_TOOL_CALLS = 120 const MAX_TEXT_CHARS = 12_000 function clampText(value, max = MAX_TEXT_CHARS) { if (typeof value !== 'string') return '' if (value.length <= max) return value return value.slice(0, max) } function asPlainObject(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return null return value } function sanitizeUsage(value) { const obj = asPlainObject(value) if (!obj) return undefined const out = {} if (typeof obj.inputTokens === 'number') out.inputTokens = obj.inputTokens if (typeof obj.outputTokens === 'number') out.outputTokens = obj.outputTokens if (typeof obj.totalTokens === 'number') out.totalTokens = obj.totalTokens return Object.keys(out).length > 0 ? out : undefined } function sanitizeToolCall(value) { const obj = asPlainObject(value) if (!obj || typeof obj.id !== 'string') return null return { id: obj.id, name: typeof obj.name === 'string' ? obj.name : 'UnknownTool', status: typeof obj.status === 'string' ? obj.status : 'completed', input: obj.input, output: obj.output, error: typeof obj.error === 'string' ? clampText(obj.error, 2_000) : undefined, startedAt: typeof obj.startedAt === 'number' ? obj.startedAt : Date.now(), completedAt: typeof obj.completedAt === 'number' ? obj.completedAt : Date.now() } } function sanitizeSubAgent(value, sessionId) { const obj = asPlainObject(value) if (!obj || typeof obj.toolUseId !== 'string') return null const toolCalls = Array.isArray(obj.toolCalls) ? obj.toolCalls.map(sanitizeToolCall).filter(Boolean).slice(-MAX_SUBAGENT_TOOL_CALLS) : [] return { name: typeof obj.name === 'string' ? clampText(obj.name, 200) : 'task', toolUseId: obj.toolUseId, sessionId, isRunning: !!obj.isRunning, iteration: Number.isFinite(obj.iteration) ? Number(obj.iteration) : 0, toolCalls, streamingText: clampText(typeof obj.streamingText === 'string' ? obj.streamingText : ''), resultOutput: clampText(typeof obj.resultOutput === 'string' ? obj.resultOutput : ''), usage: sanitizeUsage(obj.usage), error: typeof obj.error === 'string' ? clampText(obj.error, 2_000) : undefined, startedAt: typeof obj.startedAt === 'number' ? obj.startedAt : Date.now(), completedAt: typeof obj.completedAt === 'number' ? obj.completedAt : null } } function sanitizeAgentState(value, sessionId) { const obj = asPlainObject(value) ?? {} const completedSubAgentsIn = asPlainObject(obj.completedSubAgents) ?? {} const completedEntries = Object.entries(completedSubAgentsIn).slice(-MAX_COMPLETED_SUBAGENTS) const completedSubAgents = {} for (const [key, raw] of completedEntries) { const parsed = sanitizeSubAgent(raw, sessionId) if (parsed) completedSubAgents[key] = parsed } const history = Array.isArray(obj.subAgentHistory) ? obj.subAgentHistory .map((raw) => sanitizeSubAgent(raw, sessionId)) .filter(Boolean) .slice(-MAX_SUBAGENT_HISTORY) : [] const approvedToolNames = Array.isArray(obj.approvedToolNames) ? obj.approvedToolNames .filter((name) => typeof name === 'string') .map((name) => clampText(name, 200)) .slice(-MAX_APPROVED_TOOL_NAMES) : [] const sessionMemoryInjectionBySession = {} const memoryObj = asPlainObject(obj.sessionMemoryInjectionBySession) const row = memoryObj ? asPlainObject(memoryObj[sessionId]) : null if (row) { sessionMemoryInjectionBySession[sessionId] = { snapshotVersion: typeof row.snapshotVersion === 'string' ? row.snapshotVersion : null, snapshotUpdatedAt: typeof row.snapshotUpdatedAt === 'number' ? row.snapshotUpdatedAt : Date.now(), scope: row.scope === 'daily' ? 'daily' : 'user', injectedAt: typeof row.injectedAt === 'number' ? row.injectedAt : Date.now() } } return { completedSubAgents, subAgentHistory: history, approvedToolNames, sessionMemoryInjectionBySession } } function stripContentForUserView(contentStr) { if (typeof contentStr !== 'string') return contentStr try { const parsed = JSON.parse(contentStr) if (!Array.isArray(parsed)) return contentStr const filtered = parsed.filter((block) => { if (!block || typeof block !== 'object') return true const t = block.type if (AGENT_ONLY_BLOCK_TYPES.has(t)) return false return true }) if (filtered.length === parsed.length) return contentStr return JSON.stringify(filtered.length ? filtered : [{ type: 'text', text: '' }]) } catch { return contentStr } } function mapMessageDoc(doc, view) { const row = { id: doc._id, session_id: doc.session_id, role: doc.role, content: view === 'user' ? stripContentForUserView(doc.content) : doc.content, created_at: doc.created_at, usage: doc.usage ?? null, sort_order: doc.sort_order, model_id: doc.model_id ?? null, provider_id: doc.provider_id ?? null } return row } function registerChatRoutes(app, db, authRoutes) { const sessionsCol = db.collection('chat_sessions') const messagesCol = db.collection('chat_messages') const agentStateCol = db.collection('chat_session_agent_state') const withAuth = (handler) => (req, res) => { authRoutes.verifyToken(req, res, () => handler(req, res)) } const userObjectId = (req) => new ObjectId(req.user.id) const getOwnedSession = (uid, sessionId) => sessionsCol.findOne({ _id: sessionId, user_id: uid }) const assertModeAllowed = (req, res, mode) => { if (!mode) return true if (authRoutes.isModeAllowed(req.user, mode)) return true res.status(403).json({ success: false, error: `无权使用模式: ${mode}` }) return false } // --- Sessions --- app.get( '/api/chat/sessions', withAuth(async (req, res) => { try { const uid = userObjectId(req) const list = await sessionsCol.find({ user_id: uid }).sort({ updated_at: -1 }).toArray() const sessionIds = list.map((s) => s._id) let countMap = new Map() if (sessionIds.length > 0) { const counts = await messagesCol .aggregate([ { $match: { user_id: uid, session_id: { $in: sessionIds } } }, { $group: { _id: '$session_id', c: { $sum: 1 } } } ]) .toArray() countMap = new Map(counts.map((x) => [x._id, x.c])) } res.json({ success: true, sessions: list.map((s) => ({ id: s._id, title: s.title, icon: s.icon ?? null, mode: s.mode, created_at: s.created_at, updated_at: s.updated_at, project_id: s.project_id ?? null, working_folder: s.working_folder ?? null, ssh_connection_id: s.ssh_connection_id ?? null, pinned: !!s.pinned, plugin_id: s.plugin_id ?? null, provider_id: s.provider_id ?? null, model_id: s.model_id ?? null, message_count: countMap.get(s._id) ?? 0 })) }) } catch (err) { console.error('[Chat] list sessions:', err) res.status(500).json({ success: false, error: err.message }) } }) ) app.post( '/api/chat/sessions', withAuth(async (req, res) => { try { const uid = userObjectId(req) const b = req.body || {} const id = typeof b.id === 'string' && b.id ? b.id : null if (!id) { return res.status(400).json({ success: false, error: 'id required' }) } const now = Date.now() if (!assertModeAllowed(req, res, b.mode ?? 'chat')) return const doc = { _id: id, user_id: uid, title: b.title ?? 'New Conversation', icon: b.icon ?? null, mode: b.mode ?? 'chat', created_at: b.created_at ?? now, updated_at: b.updated_at ?? now, project_id: b.project_id ?? null, working_folder: b.working_folder ?? null, ssh_connection_id: b.ssh_connection_id ?? null, pinned: !!b.pinned, plugin_id: b.plugin_id ?? null, provider_id: b.provider_id ?? null, model_id: b.model_id ?? null } await sessionsCol.replaceOne({ _id: id, user_id: uid }, doc, { upsert: true }) res.json({ success: true, session: { id } }) } catch (err) { console.error('[Chat] create session:', err) res.status(500).json({ success: false, error: err.message }) } }) ) app.patch( '/api/chat/sessions/:id', withAuth(async (req, res) => { try { const uid = userObjectId(req) const id = req.params.id const patch = req.body || {} const allowed = [ 'title', 'icon', 'mode', 'updated_at', 'project_id', 'working_folder', 'ssh_connection_id', 'pinned', 'provider_id', 'model_id' ] const $set = {} for (const k of allowed) { if (patch[k] !== undefined) { if (k === 'pinned') $set.pinned = !!patch[k] else $set[k] = patch[k] } } if (patch.mode !== undefined && !assertModeAllowed(req, res, patch.mode)) return if (Object.keys($set).length === 0) { return res.json({ success: true }) } const r = await sessionsCol.updateOne({ _id: id, user_id: uid }, { $set }) if (r.matchedCount === 0) { return res.status(404).json({ success: false, error: 'Session not found' }) } res.json({ success: true }) } catch (err) { console.error('[Chat] patch session:', err) res.status(500).json({ success: false, error: err.message }) } }) ) app.delete( '/api/chat/sessions/:id', withAuth(async (req, res) => { try { const uid = userObjectId(req) const id = req.params.id await messagesCol.deleteMany({ session_id: id, user_id: uid }) await agentStateCol.deleteOne({ session_id: id, user_id: uid }) const r = await sessionsCol.deleteOne({ _id: id, user_id: uid }) if (r.deletedCount === 0) { return res.status(404).json({ success: false, error: 'Session not found' }) } res.json({ success: true }) } catch (err) { console.error('[Chat] delete session:', err) res.status(500).json({ success: false, error: err.message }) } }) ) // --- Messages --- app.get( '/api/chat/sessions/:id/messages', withAuth(async (req, res) => { try { const uid = userObjectId(req) const sessionId = req.params.id const view = req.query.view === 'user' ? 'user' : 'full' const limit = Math.min(parseInt(req.query.limit, 10) || 200, 500) const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0) const sess = await getOwnedSession(uid, sessionId) if (!sess) { return res.status(404).json({ success: false, error: 'Session not found' }) } const docs = await messagesCol .find({ session_id: sessionId, user_id: uid }) .sort({ sort_order: 1 }) .skip(offset) .limit(limit) .toArray() res.json({ success: true, messages: docs.map((d) => mapMessageDoc(d, view)) }) } catch (err) { console.error('[Chat] list messages:', err) res.status(500).json({ success: false, error: err.message }) } }) ) app.post( '/api/chat/sessions/:id/messages', withAuth(async (req, res) => { try { const uid = userObjectId(req) const sessionId = req.params.id const m = req.body || {} const sess = await getOwnedSession(uid, sessionId) if (!sess) { return res.status(404).json({ success: false, error: 'Session not found' }) } const id = typeof m.id === 'string' && m.id ? m.id : null if (!id) { return res.status(400).json({ success: false, error: 'message id required' }) } const doc = { _id: id, session_id: sessionId, user_id: uid, role: m.role, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''), created_at: m.created_at ?? Date.now(), usage: m.usage ?? null, sort_order: m.sort_order ?? 0, model_id: m.model_id ?? null, provider_id: m.provider_id ?? null } await messagesCol.replaceOne( { _id: id, session_id: sessionId, user_id: uid }, doc, { upsert: true } ) await sessionsCol.updateOne( { _id: sessionId, user_id: uid }, { $set: { updated_at: Date.now() } } ) res.json({ success: true }) } catch (err) { console.error('[Chat] add message:', err) res.status(500).json({ success: false, error: err.message }) } }) ) app.patch( '/api/chat/messages/:messageId', withAuth(async (req, res) => { try { const uid = userObjectId(req) const messageId = req.params.messageId const patch = req.body || {} const $set = {} if (patch.content !== undefined) { $set.content = typeof patch.content === 'string' ? patch.content : JSON.stringify(patch.content) } if (patch.usage !== undefined) $set.usage = patch.usage if (Object.keys($set).length === 0) { return res.json({ success: true }) } const r = await messagesCol.updateOne({ _id: messageId, user_id: uid }, { $set }) if (r.matchedCount === 0) { return res.status(404).json({ success: false, error: 'Message not found' }) } res.json({ success: true }) } catch (err) { console.error('[Chat] patch message:', err) res.status(500).json({ success: false, error: err.message }) } }) ) app.delete( '/api/chat/sessions/:id/messages/from/:fromSort', withAuth(async (req, res) => { try { const uid = userObjectId(req) const sessionId = req.params.id const fromSort = parseInt(req.params.fromSort, 10) if (!Number.isFinite(fromSort)) { return res.status(400).json({ success: false, error: 'from_sort_order required' }) } await messagesCol.deleteMany({ session_id: sessionId, user_id: uid, sort_order: { $gte: fromSort } }) await sessionsCol.updateOne( { _id: sessionId, user_id: uid }, { $set: { updated_at: Date.now() } } ) res.json({ success: true }) } catch (err) { console.error('[Chat] truncate messages:', err) res.status(500).json({ success: false, error: err.message }) } }) ) // --- Agent State (session scoped) --- app.get( '/api/chat/sessions/:id/agent-state', withAuth(async (req, res) => { try { const uid = userObjectId(req) const sessionId = req.params.id const sess = await getOwnedSession(uid, sessionId) if (!sess) { return res.status(404).json({ success: false, error: 'Session not found' }) } const doc = await agentStateCol.findOne({ session_id: sessionId, user_id: uid }) res.json({ success: true, state: doc?.state ?? null, revision: typeof doc?.revision === 'number' ? doc.revision : 0, updated_at: typeof doc?.updated_at === 'number' ? doc.updated_at : null }) } catch (err) { console.error('[Chat] get agent state:', err) res.status(500).json({ success: false, error: err.message }) } }) ) app.put( '/api/chat/sessions/:id/agent-state', withAuth(async (req, res) => { try { const uid = userObjectId(req) const sessionId = req.params.id const sess = await getOwnedSession(uid, sessionId) if (!sess) { return res.status(404).json({ success: false, error: 'Session not found' }) } const body = req.body || {} const baseRevision = typeof body.base_revision === 'number' && Number.isFinite(body.base_revision) ? body.base_revision : null const sanitized = sanitizeAgentState(body.state, sessionId) const estimatedSize = Buffer.byteLength(JSON.stringify(sanitized), 'utf8') if (estimatedSize > MAX_AGENT_STATE_BYTES) { return res.status(413).json({ success: false, error: `agent state too large (${estimatedSize} bytes)` }) } const existing = await agentStateCol.findOne({ session_id: sessionId, user_id: uid }) const currentRevision = typeof existing?.revision === 'number' ? existing.revision : 0 if (baseRevision !== null && baseRevision !== currentRevision) { return res.status(409).json({ success: false, error: 'Revision conflict', revision: currentRevision, state: existing?.state ?? null }) } const nextRevision = currentRevision + 1 const now = Date.now() await agentStateCol.updateOne( { session_id: sessionId, user_id: uid }, { $set: { session_id: sessionId, user_id: uid, state: sanitized, revision: nextRevision, updated_at: now } }, { upsert: true } ) res.json({ success: true, revision: nextRevision, updated_at: now }) } catch (err) { console.error('[Chat] put agent state:', err) res.status(500).json({ success: false, error: err.message }) } }) ) app.delete( '/api/chat/sessions/:id/agent-state', withAuth(async (req, res) => { try { const uid = userObjectId(req) const sessionId = req.params.id const sess = await getOwnedSession(uid, sessionId) if (!sess) { return res.status(404).json({ success: false, error: 'Session not found' }) } await agentStateCol.deleteOne({ session_id: sessionId, user_id: uid }) res.json({ success: true }) } catch (err) { console.error('[Chat] delete agent state:', err) res.status(500).json({ success: false, error: err.message }) } }) ) app.delete( '/api/chat/sessions/:id/messages/all', withAuth(async (req, res) => { try { const uid = userObjectId(req) const sessionId = req.params.id await messagesCol.deleteMany({ session_id: sessionId, user_id: uid }) await sessionsCol.updateOne( { _id: sessionId, user_id: uid }, { $set: { updated_at: Date.now() } } ) res.json({ success: true }) } catch (err) { console.error('[Chat] clear messages:', err) res.status(500).json({ success: false, error: err.message }) } }) ) return { sessionsCol, messagesCol } } async function ensureChatIndexes(db) { const sessionsCol = db.collection('chat_sessions') const messagesCol = db.collection('chat_messages') const agentStateCol = db.collection('chat_session_agent_state') await sessionsCol.createIndex({ user_id: 1, updated_at: -1 }) await messagesCol.createIndex({ session_id: 1, sort_order: 1 }) await messagesCol.createIndex({ user_id: 1, session_id: 1 }) await agentStateCol.createIndex({ user_id: 1, session_id: 1 }, { unique: true }) await agentStateCol.createIndex({ updated_at: -1 }) } module.exports = { registerChatRoutes, ensureChatIndexes }