Some checks failed
Deploy skills-market-server / deploy (push) Has been cancelled
- Added 'like' mode tag to various components including server, admin interface, and authentication routes for improved user interaction. - Introduced new functions in chat.js for sanitizing agent state and tool calls, enhancing data integrity and management. - Updated .gitignore to include new directories for better file management.
590 lines
20 KiB
JavaScript
590 lines
20 KiB
JavaScript
/**
|
|
* 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 }
|