912 lines
32 KiB
TypeScript
912 lines
32 KiB
TypeScript
import * as hermesCli from '../../services/hermes/hermes-cli'
|
|
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getSessionDetailFromDbWithProfile, getSessionDetailPaginatedFromDbWithProfile, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
|
|
import {
|
|
listSessions as localListSessions,
|
|
searchSessions as localSearchSessions,
|
|
getSession as localGetSession,
|
|
getSessionDetail as localGetSessionDetail,
|
|
deleteSession as localDeleteSession,
|
|
renameSession as localRenameSession,
|
|
createSession as localCreateSession,
|
|
addMessages as localAddMessages,
|
|
updateSession as localUpdateSession,
|
|
updateSessionStats as localUpdateSessionStats,
|
|
} from '../../db/hermes/session-store'
|
|
import { ExportCompressor } from '../../lib/context-compressor/export-compressor'
|
|
import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store'
|
|
import type { UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usage-store'
|
|
import { getModelContextLength } from '../../services/hermes/model-context'
|
|
import { getActiveProfileName, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
|
|
import { isPathWithin } from '../../services/hermes/hermes-path'
|
|
import { getGroupChatServer } from '../../routes/hermes/group-chat'
|
|
import { logger } from '../../services/logger'
|
|
import type { ConversationSummary } from '../../services/hermes/conversations'
|
|
import { listUserProfiles } from '../../db/hermes/users-store'
|
|
import { readConfigYamlForProfile } from '../../services/config-helpers'
|
|
|
|
function getPendingDeletedSessionIds(): Set<string> {
|
|
return getGroupChatServer()?.getStorage().getPendingDeletedSessionIds() || new Set<string>()
|
|
}
|
|
|
|
function filterPendingDeletedSessions<T extends { id: string }>(items: T[]): T[] {
|
|
const pendingIds = getPendingDeletedSessionIds()
|
|
if (pendingIds.size === 0) return items
|
|
return items.filter(item => !pendingIds.has(item.id))
|
|
}
|
|
|
|
function filterPendingDeletedConversationSummaries(items: ConversationSummary[]): ConversationSummary[] {
|
|
return filterPendingDeletedSessions(items)
|
|
}
|
|
|
|
function requestedProfile(ctx: any): string | undefined {
|
|
const value = ctx.state?.profile?.name || (typeof ctx.query?.profile === 'string' ? ctx.query.profile.trim() : '')
|
|
return value || undefined
|
|
}
|
|
|
|
function explicitProfileFilter(ctx: any): string | undefined {
|
|
const value = typeof ctx.query?.profile === 'string' ? ctx.query.profile.trim() : ''
|
|
return value || undefined
|
|
}
|
|
|
|
function allowedProfileSet(ctx: any): Set<string> | null {
|
|
const user = ctx.state?.user
|
|
if (!user || user.role === 'super_admin') return null
|
|
return new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
|
|
}
|
|
|
|
function canAccessProfile(ctx: any, profile: string | null | undefined): boolean {
|
|
const allowed = allowedProfileSet(ctx)
|
|
return !allowed || allowed.has(profile || 'default')
|
|
}
|
|
|
|
function filterByAllowedProfiles<T>(ctx: any, items: T[]): T[] {
|
|
const allowed = allowedProfileSet(ctx)
|
|
if (!allowed) return items
|
|
return items.filter(item => allowed.has(((item as any).profile as string | null | undefined) || 'default'))
|
|
}
|
|
|
|
function denySessionAccess(ctx: any, session: any | null | undefined): boolean {
|
|
if (!session || canAccessProfile(ctx, session.profile)) return false
|
|
ctx.status = 403
|
|
ctx.body = { error: `Profile "${session.profile || 'default'}" is not available for this user` }
|
|
return true
|
|
}
|
|
|
|
interface HermesDeleteResult {
|
|
attempted: boolean
|
|
deleted: boolean
|
|
profile?: string
|
|
error?: string
|
|
}
|
|
|
|
interface BatchDeleteTarget {
|
|
id: string
|
|
profile?: string | null
|
|
}
|
|
|
|
interface ProfileDefaultModel {
|
|
model: string
|
|
provider: string
|
|
}
|
|
|
|
interface LocalImportMessage {
|
|
session_id: string
|
|
role: string
|
|
content: string
|
|
tool_call_id?: string | null
|
|
tool_calls?: any[] | null
|
|
tool_name?: string | null
|
|
timestamp?: number
|
|
token_count?: number | null
|
|
finish_reason?: string | null
|
|
reasoning?: string | null
|
|
reasoning_details?: string | null
|
|
reasoning_content?: string | null
|
|
}
|
|
|
|
function hasProfileOnDisk(profile: string): boolean {
|
|
return listProfileNamesFromDisk().includes(profile || 'default')
|
|
}
|
|
|
|
async function deleteHermesSessionIfPresent(sessionId: string, profile?: string | null): Promise<HermesDeleteResult> {
|
|
const targetProfile = profile || 'default'
|
|
if (!hasProfileOnDisk(targetProfile)) {
|
|
return { attempted: false, deleted: false, profile: targetProfile }
|
|
}
|
|
|
|
try {
|
|
const hermesSession = await getExactSessionDetailFromDbWithProfile(sessionId, targetProfile)
|
|
if (!hermesSession) {
|
|
return { attempted: false, deleted: false, profile: targetProfile }
|
|
}
|
|
|
|
const deleted = await hermesCli.deleteSessionForProfile(sessionId, targetProfile)
|
|
return {
|
|
attempted: true,
|
|
deleted,
|
|
profile: targetProfile,
|
|
error: deleted ? undefined : 'Failed to delete Hermes session',
|
|
}
|
|
} catch (err: any) {
|
|
const message = err?.message || 'Failed to inspect Hermes session'
|
|
logger.warn({ err, sessionId, profile: targetProfile }, 'Hermes Session: profile delete skipped')
|
|
return { attempted: true, deleted: false, profile: targetProfile, error: message }
|
|
}
|
|
}
|
|
|
|
async function getProfileDefaultModel(profile: string): Promise<ProfileDefaultModel> {
|
|
try {
|
|
const config = await readConfigYamlForProfile(profile)
|
|
const modelSection = config?.model
|
|
if (modelSection && typeof modelSection === 'object' && !Array.isArray(modelSection)) {
|
|
return {
|
|
model: String(modelSection.default || '').trim(),
|
|
provider: String(modelSection.provider || '').trim(),
|
|
}
|
|
}
|
|
if (typeof modelSection === 'string') {
|
|
return { model: modelSection.trim(), provider: '' }
|
|
}
|
|
} catch (err) {
|
|
logger.warn({ err, profile }, 'Hermes Session: failed to read profile default model for import')
|
|
}
|
|
return { model: '', provider: '' }
|
|
}
|
|
|
|
function normalizeImportText(value: unknown): string {
|
|
if (value == null) return ''
|
|
if (typeof value === 'string') return value
|
|
try {
|
|
return JSON.stringify(value)
|
|
} catch {
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
function normalizeImportNullableText(value: unknown): string | null {
|
|
const text = normalizeImportText(value)
|
|
return text ? text : null
|
|
}
|
|
|
|
function normalizeImportToolCalls(value: unknown): any[] | null {
|
|
if (!Array.isArray(value)) return null
|
|
const calls = value
|
|
.map((call: any) => {
|
|
const id = String(call?.id || '').trim()
|
|
const fn = call?.function && typeof call.function === 'object' ? call.function : {}
|
|
const name = String(fn.name || call?.name || '').trim()
|
|
if (!id || !name) return null
|
|
const rawArgs = fn.arguments ?? call?.arguments ?? {}
|
|
const args = typeof rawArgs === 'string' ? rawArgs : normalizeImportText(rawArgs || {})
|
|
return {
|
|
id,
|
|
type: String(call?.type || 'function'),
|
|
function: { name, arguments: args || '{}' },
|
|
}
|
|
})
|
|
.filter((call): call is { id: string; type: string; function: { name: string; arguments: string } } => Boolean(call))
|
|
return calls.length > 0 ? calls : null
|
|
}
|
|
|
|
function buildImportMessages(sessionId: string, messages: any[]): LocalImportMessage[] {
|
|
const result: LocalImportMessage[] = []
|
|
const knownToolCallIds = new Set<string>()
|
|
|
|
for (const message of messages) {
|
|
const role = String(message?.role || '').trim()
|
|
if (role !== 'user' && role !== 'assistant' && role !== 'tool') continue
|
|
|
|
const toolCalls = role === 'assistant' ? normalizeImportToolCalls(message.tool_calls) : null
|
|
if (toolCalls) {
|
|
for (const call of toolCalls) knownToolCallIds.add(call.id)
|
|
}
|
|
|
|
if (role === 'tool') {
|
|
const callId = String(message?.tool_call_id || '').trim()
|
|
if (!callId || !knownToolCallIds.has(callId)) continue
|
|
result.push({
|
|
session_id: sessionId,
|
|
role,
|
|
content: normalizeImportText(message?.content),
|
|
tool_call_id: callId,
|
|
tool_calls: null,
|
|
tool_name: normalizeImportNullableText(message?.tool_name),
|
|
timestamp: Number(message?.timestamp || 0),
|
|
token_count: message?.token_count == null ? null : Number(message.token_count),
|
|
finish_reason: normalizeImportNullableText(message?.finish_reason),
|
|
reasoning: null,
|
|
reasoning_details: null,
|
|
reasoning_content: null,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const content = normalizeImportText(message?.content)
|
|
if (role === 'assistant' && !content.trim() && !toolCalls) continue
|
|
|
|
result.push({
|
|
session_id: sessionId,
|
|
role,
|
|
content,
|
|
tool_call_id: null,
|
|
tool_calls: toolCalls,
|
|
tool_name: null,
|
|
timestamp: Number(message?.timestamp || 0),
|
|
token_count: message?.token_count == null ? null : Number(message.token_count),
|
|
finish_reason: normalizeImportNullableText(message?.finish_reason),
|
|
reasoning: role === 'assistant' ? normalizeImportNullableText(message?.reasoning) : null,
|
|
reasoning_details: role === 'assistant' ? normalizeImportNullableText(message?.reasoning_details) : null,
|
|
reasoning_content: role === 'assistant' ? normalizeImportNullableText(message?.reasoning_content) : null,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
export async function listConversations(ctx: any) {
|
|
const source = (ctx.query.source as string) || undefined
|
|
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
|
|
|
const profile = explicitProfileFilter(ctx)
|
|
const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200)
|
|
const summaries: ConversationSummary[] = sessions.map(s => ({
|
|
id: s.id,
|
|
profile: s.profile || null,
|
|
source: s.source,
|
|
model: s.model,
|
|
provider: s.provider,
|
|
title: s.title,
|
|
started_at: s.started_at,
|
|
ended_at: s.ended_at,
|
|
last_active: s.last_active,
|
|
message_count: s.message_count,
|
|
tool_call_count: s.tool_call_count,
|
|
input_tokens: s.input_tokens,
|
|
output_tokens: s.output_tokens,
|
|
cache_read_tokens: s.cache_read_tokens,
|
|
cache_write_tokens: s.cache_write_tokens,
|
|
reasoning_tokens: s.reasoning_tokens,
|
|
billing_provider: s.billing_provider,
|
|
estimated_cost_usd: s.estimated_cost_usd,
|
|
actual_cost_usd: s.actual_cost_usd,
|
|
cost_status: s.cost_status,
|
|
preview: s.preview,
|
|
workspace: s.workspace || null,
|
|
is_active: s.ended_at == null && (Date.now() / 1000 - s.last_active) <= 300,
|
|
thread_session_count: 1,
|
|
}))
|
|
ctx.body = { sessions: filterPendingDeletedConversationSummaries(filterByAllowedProfiles(ctx, summaries)) }
|
|
}
|
|
|
|
export async function getConversationMessages(ctx: any) {
|
|
const humanOnly = (ctx.query.humanOnly as string) !== 'false' && ctx.query.humanOnly !== '0'
|
|
|
|
const detail = localGetSessionDetail(ctx.params.id)
|
|
if (!detail) {
|
|
ctx.status = 404
|
|
ctx.body = { error: 'Conversation not found' }
|
|
return
|
|
}
|
|
if (denySessionAccess(ctx, detail)) return
|
|
const messages = detail.messages
|
|
.filter(m => {
|
|
if (humanOnly && m.role !== 'user' && m.role !== 'assistant') return false
|
|
if (!m.content) return false
|
|
return true
|
|
})
|
|
.map(m => ({
|
|
id: m.id,
|
|
session_id: m.session_id,
|
|
role: m.role as 'user' | 'assistant',
|
|
content: m.content,
|
|
timestamp: m.timestamp,
|
|
}))
|
|
ctx.body = {
|
|
session_id: ctx.params.id,
|
|
messages,
|
|
visible_count: messages.length,
|
|
thread_session_count: 1,
|
|
}
|
|
}
|
|
|
|
export async function list(ctx: any) {
|
|
const source = (ctx.query.source as string) || undefined
|
|
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
|
const profile = explicitProfileFilter(ctx)
|
|
const effectiveLimit = limit && limit > 0 ? limit : 2000
|
|
|
|
const allSessions = localListSessions(profile, source, effectiveLimit)
|
|
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
|
|
ctx.body = {
|
|
sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s =>
|
|
(s.source === 'api_server' || s.source === 'cli') &&
|
|
(!knownProfiles || knownProfiles.has(s.profile || 'default')),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List Hermes sessions only (exclude api_server source)
|
|
* GET /api/hermes/sessions/hermes?source=&limit=
|
|
*/
|
|
export async function listHermesSessions(ctx: any) {
|
|
const source = (ctx.query.source as string) || undefined
|
|
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
|
const profile = requestedProfile(ctx)
|
|
const effectiveLimit = limit && limit > 0 ? limit : 2000
|
|
|
|
const importedIds = new Set(localListSessions(profile, undefined, effectiveLimit).map(session => session.id))
|
|
const allSessions = (await listSessionSummaries(source, effectiveLimit, profile))
|
|
.map(session => ({
|
|
...(profile ? { ...session, profile } : session),
|
|
webui_imported: importedIds.has(session.id),
|
|
}))
|
|
ctx.body = { sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s => s.source !== 'api_server')) }
|
|
}
|
|
|
|
export async function search(ctx: any) {
|
|
const q = typeof ctx.query.q === 'string' ? ctx.query.q : ''
|
|
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
|
const profile = explicitProfileFilter(ctx)
|
|
const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20)
|
|
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
|
|
ctx.body = {
|
|
results: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, results).filter(s =>
|
|
!knownProfiles || knownProfiles.has(s.profile || 'default'),
|
|
)),
|
|
}
|
|
}
|
|
|
|
export async function get(ctx: any) {
|
|
const session = localGetSessionDetail(ctx.params.id)
|
|
if (!session) {
|
|
ctx.status = 404
|
|
ctx.body = { error: 'Session not found' }
|
|
return
|
|
}
|
|
if (denySessionAccess(ctx, session)) return
|
|
ctx.body = { session }
|
|
}
|
|
|
|
/**
|
|
* Get Hermes session detail only (exclude api_server source)
|
|
* GET /api/hermes/sessions/hermes/:id
|
|
*/
|
|
export async function getHermesSession(ctx: any) {
|
|
const profile = requestedProfile(ctx)
|
|
|
|
// Prefer the Web UI local session store. Hermes state.db can lag behind or
|
|
// miss messages for Bridge-backed runs, while the local store is the source
|
|
// used by chat rendering and compression.
|
|
const localSession = localGetSessionDetail(ctx.params.id)
|
|
const localSessionProfile = (localSession?.profile || 'default') as string
|
|
if (localSession && localSession.source !== 'api_server' && (!profile || localSessionProfile === profile)) {
|
|
if (denySessionAccess(ctx, localSession)) return
|
|
ctx.body = { session: localSession }
|
|
return
|
|
}
|
|
|
|
// Try Hermes state.db next (consistent with listHermesSessions)
|
|
try {
|
|
const session = profile
|
|
? await getSessionDetailFromDbWithProfile(ctx.params.id, profile)
|
|
: await getSessionDetailFromDb(ctx.params.id)
|
|
if (session && session.source !== 'api_server') {
|
|
const sessionWithProfile = profile ? { ...session, profile } : session
|
|
if (denySessionAccess(ctx, sessionWithProfile)) return
|
|
ctx.body = { session: sessionWithProfile }
|
|
return
|
|
}
|
|
} catch (err) {
|
|
logger.warn(err, 'Hermes Session DB: detail query failed, falling back to CLI')
|
|
}
|
|
|
|
// Fallback to CLI
|
|
const session = await hermesCli.getSession(ctx.params.id)
|
|
if (!session) {
|
|
ctx.status = 404
|
|
ctx.body = { error: 'Session not found' }
|
|
return
|
|
}
|
|
// Filter out api_server sessions
|
|
if (session.source === 'api_server') {
|
|
ctx.status = 404
|
|
ctx.body = { error: 'Session not found' }
|
|
return
|
|
}
|
|
if (denySessionAccess(ctx, session)) return
|
|
ctx.body = { session }
|
|
}
|
|
|
|
export async function importHermesSession(ctx: any) {
|
|
const sessionId = ctx.params.id
|
|
const profile = requestedProfile(ctx) || getActiveProfileName()
|
|
if (!canAccessProfile(ctx, profile)) {
|
|
ctx.status = 403
|
|
ctx.body = { error: `Profile "${profile || 'default'}" is not available for this user` }
|
|
return
|
|
}
|
|
|
|
const existing = localGetSessionDetail(sessionId)
|
|
if (existing) {
|
|
ctx.body = { ok: true, imported: false, session: existing }
|
|
return
|
|
}
|
|
|
|
let detail
|
|
try {
|
|
detail = await getSessionDetailFromDbWithProfile(sessionId, profile)
|
|
} catch (err) {
|
|
logger.warn({ err, sessionId, profile }, 'Hermes Session: import query failed')
|
|
ctx.status = 500
|
|
ctx.body = { error: 'Failed to read Hermes session' }
|
|
return
|
|
}
|
|
|
|
if (!detail || detail.source === 'api_server') {
|
|
ctx.status = 404
|
|
ctx.body = { error: 'Session not found' }
|
|
return
|
|
}
|
|
|
|
const profileDefault = await getProfileDefaultModel(profile)
|
|
const importTimestamp = Math.floor(Date.now() / 1000)
|
|
|
|
localCreateSession({
|
|
id: detail.id,
|
|
profile,
|
|
source: 'cli',
|
|
model: profileDefault.model,
|
|
provider: profileDefault.provider,
|
|
title: detail.title || undefined,
|
|
})
|
|
|
|
localUpdateSession(detail.id, {
|
|
source: 'cli',
|
|
user_id: detail.user_id,
|
|
model: profileDefault.model,
|
|
provider: profileDefault.provider,
|
|
title: detail.title,
|
|
started_at: detail.started_at,
|
|
ended_at: detail.ended_at,
|
|
end_reason: detail.end_reason,
|
|
message_count: detail.message_count,
|
|
tool_call_count: detail.tool_call_count,
|
|
input_tokens: detail.input_tokens,
|
|
output_tokens: detail.output_tokens,
|
|
cache_read_tokens: detail.cache_read_tokens,
|
|
cache_write_tokens: detail.cache_write_tokens,
|
|
reasoning_tokens: detail.reasoning_tokens,
|
|
billing_provider: detail.billing_provider,
|
|
estimated_cost_usd: detail.estimated_cost_usd,
|
|
actual_cost_usd: detail.actual_cost_usd,
|
|
cost_status: detail.cost_status,
|
|
preview: detail.preview,
|
|
last_active: importTimestamp,
|
|
})
|
|
|
|
const importMessages = buildImportMessages(detail.id, Array.isArray(detail.messages) ? detail.messages : [])
|
|
localAddMessages(importMessages)
|
|
localUpdateSessionStats(detail.id)
|
|
localUpdateSession(detail.id, {
|
|
tool_call_count: detail.tool_call_count,
|
|
input_tokens: detail.input_tokens,
|
|
output_tokens: detail.output_tokens,
|
|
cache_read_tokens: detail.cache_read_tokens,
|
|
cache_write_tokens: detail.cache_write_tokens,
|
|
reasoning_tokens: detail.reasoning_tokens,
|
|
billing_provider: detail.billing_provider,
|
|
estimated_cost_usd: detail.estimated_cost_usd,
|
|
actual_cost_usd: detail.actual_cost_usd,
|
|
cost_status: detail.cost_status,
|
|
last_active: importTimestamp,
|
|
ended_at: detail.ended_at,
|
|
})
|
|
|
|
ctx.body = { ok: true, imported: true, session: localGetSessionDetail(detail.id) }
|
|
}
|
|
|
|
export async function remove(ctx: any) {
|
|
const sessionId = ctx.params.id
|
|
const existing = localGetSession(sessionId)
|
|
if (denySessionAccess(ctx, existing)) return
|
|
const hermesProfile = requestedProfile(ctx) || existing?.profile || getActiveProfileName()
|
|
const hermes = await deleteHermesSessionIfPresent(sessionId, hermesProfile)
|
|
const localDeleted = existing ? localDeleteSession(sessionId) : true
|
|
if (!localDeleted) {
|
|
ctx.status = 500
|
|
ctx.body = { error: 'Failed to delete session' }
|
|
return
|
|
}
|
|
deleteUsage(sessionId)
|
|
ctx.body = { ok: true, deleted: Boolean(existing), hermes }
|
|
}
|
|
|
|
export async function batchRemove(ctx: any) {
|
|
const { ids, sessions } = ctx.request.body as { ids?: string[]; sessions?: BatchDeleteTarget[] }
|
|
const rawTargets = Array.isArray(sessions) && sessions.length > 0 ? sessions : ids
|
|
if (!rawTargets || !Array.isArray(rawTargets) || rawTargets.length === 0) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'ids is required and must be a non-empty array' }
|
|
return
|
|
}
|
|
|
|
const targets = rawTargets
|
|
.map((target): BatchDeleteTarget | null => {
|
|
if (typeof target === 'string') {
|
|
const id = target.trim()
|
|
return id ? { id } : null
|
|
}
|
|
if (!target || typeof target.id !== 'string') return null
|
|
const id = target.id.trim()
|
|
if (!id) return null
|
|
const profile = typeof target.profile === 'string' && target.profile.trim()
|
|
? target.profile.trim()
|
|
: undefined
|
|
return { id, profile }
|
|
})
|
|
.filter((target): target is BatchDeleteTarget => Boolean(target))
|
|
|
|
if (targets.length === 0) {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'No valid session ids provided' }
|
|
return
|
|
}
|
|
|
|
const results = {
|
|
deleted: 0,
|
|
failed: 0,
|
|
hermesDeleted: 0,
|
|
hermesFailed: 0,
|
|
errors: [] as Array<{ id: string; error: string }>,
|
|
hermesErrors: [] as Array<{ id: string; profile?: string; error: string }>
|
|
}
|
|
|
|
for (const target of targets) {
|
|
const { id } = target
|
|
const existing = localGetSession(id)
|
|
const targetProfile = target.profile || existing?.profile
|
|
if (targetProfile && !canAccessProfile(ctx, targetProfile)) {
|
|
results.failed++
|
|
results.errors.push({ id, error: `Profile "${targetProfile || 'default'}" is not available for this user` })
|
|
continue
|
|
}
|
|
if (!targetProfile && existing && !canAccessProfile(ctx, existing.profile)) {
|
|
results.failed++
|
|
results.errors.push({ id, error: `Profile "${existing.profile || 'default'}" is not available for this user` })
|
|
continue
|
|
}
|
|
|
|
const hermes = await deleteHermesSessionIfPresent(id, targetProfile)
|
|
if (hermes.deleted) {
|
|
results.hermesDeleted++
|
|
} else if (hermes.attempted && hermes.error) {
|
|
results.hermesFailed++
|
|
results.hermesErrors.push({ id, profile: hermes.profile, error: hermes.error })
|
|
}
|
|
|
|
const shouldDeleteLocal = Boolean(existing && (!targetProfile || existing.profile === targetProfile))
|
|
if (shouldDeleteLocal) {
|
|
const ok = localDeleteSession(id)
|
|
if (ok) {
|
|
deleteUsage(id)
|
|
results.deleted++
|
|
} else {
|
|
results.failed++
|
|
results.errors.push({ id, error: 'Failed to delete session' })
|
|
}
|
|
} else if (hermes.deleted) {
|
|
results.deleted++
|
|
} else {
|
|
results.failed++
|
|
results.errors.push({ id, error: 'Session not found' })
|
|
}
|
|
}
|
|
|
|
ctx.body = { ...results, ok: true }
|
|
}
|
|
|
|
export async function usageBatch(ctx: any) {
|
|
const ids = (ctx.query.ids as string)
|
|
if (!ids) {
|
|
ctx.body = {}
|
|
return
|
|
}
|
|
const idList = ids.split(',').filter(Boolean)
|
|
ctx.body = getUsageBatch(idList)
|
|
}
|
|
|
|
export async function usageSingle(ctx: any) {
|
|
const session = localGetSession(ctx.params.id)
|
|
if (denySessionAccess(ctx, session)) return
|
|
const result = getUsage(ctx.params.id)
|
|
if (!result) {
|
|
ctx.body = { input_tokens: 0, output_tokens: 0 }
|
|
return
|
|
}
|
|
ctx.body = result
|
|
}
|
|
|
|
export async function rename(ctx: any) {
|
|
const { title } = ctx.request.body as { title?: string }
|
|
if (!title || typeof title !== 'string') {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'title is required' }
|
|
return
|
|
}
|
|
const existing = localGetSession(ctx.params.id)
|
|
if (denySessionAccess(ctx, existing)) return
|
|
const ok = localRenameSession(ctx.params.id, title.trim())
|
|
if (!ok) {
|
|
ctx.status = 500
|
|
ctx.body = { error: 'Failed to rename session' }
|
|
return
|
|
}
|
|
ctx.body = { ok: true }
|
|
}
|
|
|
|
export async function setWorkspace(ctx: any) {
|
|
const { workspace } = ctx.request.body as { workspace?: string }
|
|
if (workspace !== undefined && workspace !== null && typeof workspace !== 'string') {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'workspace must be a string or null' }
|
|
return
|
|
}
|
|
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
|
|
const id = ctx.params.id
|
|
const existing = getSession(id)
|
|
if (denySessionAccess(ctx, existing)) return
|
|
if (!existing) {
|
|
createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' })
|
|
}
|
|
updateSession(id, { workspace: workspace || null } as any)
|
|
ctx.body = { ok: true }
|
|
}
|
|
|
|
export async function setModel(ctx: any) {
|
|
const { model, provider } = ctx.request.body as { model?: string; provider?: string }
|
|
if (!model || typeof model !== 'string') {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'model is required' }
|
|
return
|
|
}
|
|
if (provider !== undefined && provider !== null && typeof provider !== 'string') {
|
|
ctx.status = 400
|
|
ctx.body = { error: 'provider must be a string' }
|
|
return
|
|
}
|
|
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
|
|
const id = ctx.params.id
|
|
const existing = getSession(id)
|
|
if (denySessionAccess(ctx, existing)) return
|
|
if (!existing) {
|
|
createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' })
|
|
}
|
|
updateSession(id, { model: model.trim(), provider: (provider || '').trim() } as any)
|
|
ctx.body = { ok: true }
|
|
}
|
|
|
|
export async function contextLength(ctx: any) {
|
|
const profile = requestedProfile(ctx)
|
|
const model = typeof ctx.query.model === 'string' ? ctx.query.model : undefined
|
|
const provider = typeof ctx.query.provider === 'string' ? ctx.query.provider : undefined
|
|
ctx.body = { context_length: getModelContextLength({ profile, model, provider }) }
|
|
}
|
|
|
|
export async function usageStats(ctx: any) {
|
|
const rawDays = parseInt(String(ctx.query?.days ?? '30'), 10)
|
|
const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30
|
|
const profile = requestedProfile(ctx)
|
|
|
|
let hermes = {
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
cache_read_tokens: 0,
|
|
cache_write_tokens: 0,
|
|
reasoning_tokens: 0,
|
|
sessions: 0,
|
|
by_model: [] as UsageStatsModelRow[],
|
|
by_day: [] as UsageStatsDailyRow[],
|
|
cost: 0,
|
|
total_api_calls: 0,
|
|
}
|
|
|
|
try {
|
|
hermes = profile ? await getUsageStatsFromDb(days, undefined, profile) : await getUsageStatsFromDb(days)
|
|
} catch (err) {
|
|
logger.warn(err, 'usageStats: failed to load Hermes usage analytics from state.db')
|
|
}
|
|
|
|
const dayMap = new Map<string, UsageStatsDailyRow>()
|
|
const now = new Date()
|
|
for (let i = days - 1; i >= 0; i--) {
|
|
const d = new Date(now)
|
|
d.setDate(d.getDate() - i)
|
|
const key = d.toISOString().slice(0, 10)
|
|
dayMap.set(key, { date: key, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, sessions: 0, errors: 0, cost: 0 })
|
|
}
|
|
for (const d of hermes.by_day) {
|
|
const existing = dayMap.get(d.date)
|
|
if (existing) {
|
|
existing.input_tokens += d.input_tokens; existing.output_tokens += d.output_tokens
|
|
existing.cache_read_tokens += d.cache_read_tokens; existing.cache_write_tokens += d.cache_write_tokens
|
|
existing.sessions += d.sessions; existing.errors += d.errors; existing.cost += d.cost
|
|
}
|
|
}
|
|
|
|
ctx.body = {
|
|
total_input_tokens: hermes.input_tokens,
|
|
total_output_tokens: hermes.output_tokens,
|
|
total_cache_read_tokens: hermes.cache_read_tokens,
|
|
total_cache_write_tokens: hermes.cache_write_tokens,
|
|
total_reasoning_tokens: hermes.reasoning_tokens,
|
|
total_sessions: hermes.sessions,
|
|
total_cost: hermes.cost,
|
|
total_api_calls: hermes.total_api_calls,
|
|
period_days: days,
|
|
model_usage: hermes.by_model.sort((a, b) => (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens)),
|
|
daily_usage: [...dayMap.values()],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List folders under workspace base path for folder picker.
|
|
* GET /api/hermes/workspace/folders?path=<relative_path>
|
|
* Base: /opt/data/workspace (overridable via WORKSPACE_BASE env)
|
|
*/
|
|
export async function listWorkspaceFolders(ctx: any) {
|
|
const { resolve, join } = await import('path')
|
|
const { readdir } = await import('fs/promises')
|
|
const { existsSync } = await import('fs')
|
|
|
|
const WORKSPACE_BASE = process.env.WORKSPACE_BASE || '/opt/data/workspace'
|
|
const subPath = (ctx.query.path as string) || ''
|
|
|
|
// Security: prevent path traversal
|
|
const fullPath = resolve(join(WORKSPACE_BASE, subPath))
|
|
if (!isPathWithin(fullPath, WORKSPACE_BASE)) {
|
|
ctx.status = 403
|
|
ctx.body = { error: 'Access denied' }
|
|
return
|
|
}
|
|
|
|
if (!existsSync(fullPath)) {
|
|
ctx.status = 404
|
|
ctx.body = { error: 'Path not found', folders: [] }
|
|
return
|
|
}
|
|
|
|
try {
|
|
const entries = await readdir(fullPath, { withFileTypes: true })
|
|
const folders = entries
|
|
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
.map(e => ({
|
|
name: e.name,
|
|
path: subPath ? `${subPath}/${e.name}` : e.name,
|
|
fullPath: join(fullPath, e.name),
|
|
}))
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
|
|
ctx.body = { base: WORKSPACE_BASE, current: subPath, folders }
|
|
} catch (err: any) {
|
|
ctx.status = 500
|
|
ctx.body = { error: err.message }
|
|
}
|
|
}
|
|
|
|
const exportCompressor = new ExportCompressor()
|
|
|
|
export async function exportSession(ctx: any) {
|
|
const session = localGetSessionDetail(ctx.params.id)
|
|
|
|
if (!session) {
|
|
ctx.status = 404
|
|
ctx.body = { error: 'Session not found' }
|
|
return
|
|
}
|
|
if (denySessionAccess(ctx, session)) return
|
|
|
|
const mode = (ctx.query.mode as string) || 'full'
|
|
const ext = (ctx.query.ext as string) || (mode === 'compressed' ? 'txt' : 'json')
|
|
const title = session.title || 'session'
|
|
const safeName = title.replace(/[^a-zA-Z0-9一-鿿_-]/g, '_').slice(0, 50)
|
|
const filename = `${safeName}_${ctx.params.id.slice(0, 8)}.${ext}`
|
|
|
|
if (mode === 'compressed') {
|
|
const result = await compressSession(session)
|
|
if (ext === 'json') {
|
|
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
|
|
ctx.set('Content-Type', 'application/json')
|
|
ctx.body = JSON.stringify({ id: session.id, title: session.title, ...result.meta, messages: result.messages }, null, 2)
|
|
} else {
|
|
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
|
|
ctx.set('Content-Type', 'text/plain; charset=utf-8')
|
|
ctx.body = serializeAsText(session.title, result.messages)
|
|
}
|
|
} else {
|
|
if (ext === 'txt') {
|
|
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
|
|
ctx.set('Content-Type', 'text/plain; charset=utf-8')
|
|
ctx.body = serializeAsText(session.title, session.messages || [])
|
|
} else {
|
|
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
|
|
ctx.set('Content-Type', 'application/json')
|
|
ctx.body = JSON.stringify(session, null, 2)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function compressSession(session: any) {
|
|
const profile = session.profile || getActiveProfileName()
|
|
const upstream = ''
|
|
const apiKey = undefined
|
|
const messages = (session.messages || []).map((m: any) => ({
|
|
role: m.role,
|
|
content: m.content || '',
|
|
tool_calls: m.tool_calls,
|
|
tool_call_id: m.tool_call_id,
|
|
name: m.tool_name,
|
|
reasoning_content: m.reasoning,
|
|
}))
|
|
|
|
return exportCompressor.compress(messages, upstream, apiKey, session.id, {
|
|
profile,
|
|
model: session.model,
|
|
provider: session.provider,
|
|
})
|
|
}
|
|
|
|
function serializeAsText(title: string | null, messages: any[]): string {
|
|
const lines: string[] = [`# ${title || 'Untitled'}`, '']
|
|
for (const msg of messages) {
|
|
const role = msg.role || 'unknown'
|
|
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
|
|
const ts = msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : ''
|
|
lines.push(`[${role}]${ts ? ' ' + ts : ''}`)
|
|
lines.push(content || '')
|
|
lines.push('')
|
|
}
|
|
return lines.join('\n')
|
|
}
|
|
|
|
export async function getConversationMessagesPaginated(ctx: any) {
|
|
const offset = ctx.query.offset ? parseInt(ctx.query.offset as string, 10) : 0
|
|
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : 50
|
|
const profile = requestedProfile(ctx)
|
|
|
|
const { getSessionDetailPaginated } = await import('../../db/hermes/session-store')
|
|
const localResult = getSessionDetailPaginated(ctx.params.id, offset, limit)
|
|
const result = localResult && (!profile || localResult.session.profile === profile)
|
|
? localResult
|
|
: await getSessionDetailPaginatedFromDbWithProfile(ctx.params.id, profile || 'default', offset, limit)
|
|
|
|
if (!result) {
|
|
ctx.status = 404
|
|
ctx.body = { error: 'Conversation not found' }
|
|
return
|
|
}
|
|
const session = { ...result.session, profile: (result.session as any).profile || profile || 'default' }
|
|
if (denySessionAccess(ctx, session)) return
|
|
|
|
ctx.body = {
|
|
session: {
|
|
id: session.id,
|
|
profile: session.profile,
|
|
source: session.source,
|
|
model: session.model,
|
|
title: session.title,
|
|
started_at: session.started_at,
|
|
ended_at: session.ended_at,
|
|
last_active: session.last_active,
|
|
message_count: session.message_count,
|
|
input_tokens: session.input_tokens,
|
|
output_tokens: session.output_tokens,
|
|
},
|
|
messages: result.messages,
|
|
total: result.total,
|
|
offset: result.offset,
|
|
limit: result.limit,
|
|
hasMore: result.hasMore,
|
|
}
|
|
}
|