Add history import controls (#1053)
This commit is contained in:
@@ -23,6 +23,7 @@ export interface SessionSummary {
|
|||||||
actual_cost_usd: number | null
|
actual_cost_usd: number | null
|
||||||
cost_status: string
|
cost_status: string
|
||||||
workspace?: string | null
|
workspace?: string | null
|
||||||
|
webui_imported?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionDetail extends SessionSummary {
|
export interface SessionDetail extends SessionSummary {
|
||||||
@@ -122,6 +123,16 @@ export async function deleteSession(id: string, profile?: string | null): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importHermesSession(id: string, profile?: string | null): Promise<{ ok: boolean; imported: boolean; session?: SessionDetail }> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (profile) params.set('profile', profile)
|
||||||
|
const query = params.toString()
|
||||||
|
return request<{ ok: boolean; imported: boolean; session?: SessionDetail }>(
|
||||||
|
`/api/hermes/sessions/hermes/${encodeURIComponent(id)}/import${query ? `?${query}` : ''}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export interface BatchDeleteSessionTarget {
|
export interface BatchDeleteSessionTarget {
|
||||||
id: string
|
id: string
|
||||||
profile?: string | null
|
profile?: string | null
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export default {
|
|||||||
batchDeleteSuccess: '{count} Sitzungen gelöscht',
|
batchDeleteSuccess: '{count} Sitzungen gelöscht',
|
||||||
batchDeletePartial: '{failed} Sitzungen konnten nicht gelöscht werden',
|
batchDeletePartial: '{failed} Sitzungen konnten nicht gelöscht werden',
|
||||||
batchDeleteFailed: 'Batch-Löschung fehlgeschlagen',
|
batchDeleteFailed: 'Batch-Löschung fehlgeschlagen',
|
||||||
|
importToWebUi: 'In Web UI importieren',
|
||||||
|
importSessionSuccess: 'Sitzung in Web UI importiert',
|
||||||
|
importSessionAlreadyExists: 'Sitzung existiert bereits in Web UI',
|
||||||
|
importSessionFailed: 'Sitzung konnte nicht importiert werden',
|
||||||
sessionDeleted: 'Sitzung geloscht',
|
sessionDeleted: 'Sitzung geloscht',
|
||||||
rename: 'Umbenennen',
|
rename: 'Umbenennen',
|
||||||
pin: 'Anheften',
|
pin: 'Anheften',
|
||||||
|
|||||||
@@ -286,6 +286,10 @@ export default {
|
|||||||
batchDeleteSuccess: 'Deleted {count} sessions',
|
batchDeleteSuccess: 'Deleted {count} sessions',
|
||||||
batchDeletePartial: '{failed} sessions failed to delete',
|
batchDeletePartial: '{failed} sessions failed to delete',
|
||||||
batchDeleteFailed: 'Batch delete failed',
|
batchDeleteFailed: 'Batch delete failed',
|
||||||
|
importToWebUi: 'Import to Web UI',
|
||||||
|
importSessionSuccess: 'Session imported to Web UI',
|
||||||
|
importSessionAlreadyExists: 'Session already exists in Web UI',
|
||||||
|
importSessionFailed: 'Failed to import session',
|
||||||
rename: 'Rename',
|
rename: 'Rename',
|
||||||
pin: 'Pin',
|
pin: 'Pin',
|
||||||
unpin: 'Unpin',
|
unpin: 'Unpin',
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export default {
|
|||||||
batchDeleteSuccess: '{count} sesiones eliminadas',
|
batchDeleteSuccess: '{count} sesiones eliminadas',
|
||||||
batchDeletePartial: '{failed} sesiones fallaron al eliminar',
|
batchDeletePartial: '{failed} sesiones fallaron al eliminar',
|
||||||
batchDeleteFailed: 'Error al eliminar por lotes',
|
batchDeleteFailed: 'Error al eliminar por lotes',
|
||||||
|
importToWebUi: 'Importar a Web UI',
|
||||||
|
importSessionSuccess: 'Sesion importada a Web UI',
|
||||||
|
importSessionAlreadyExists: 'La sesion ya existe en Web UI',
|
||||||
|
importSessionFailed: 'Error al importar la sesion',
|
||||||
sessionDeleted: 'Sesion eliminada',
|
sessionDeleted: 'Sesion eliminada',
|
||||||
rename: 'Renombrar',
|
rename: 'Renombrar',
|
||||||
pin: 'Fijar',
|
pin: 'Fijar',
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export default {
|
|||||||
batchDeleteSuccess: '{count} sessions supprimées',
|
batchDeleteSuccess: '{count} sessions supprimées',
|
||||||
batchDeletePartial: '{failed} sessions ont échoué',
|
batchDeletePartial: '{failed} sessions ont échoué',
|
||||||
batchDeleteFailed: 'Échec de la suppression par lot',
|
batchDeleteFailed: 'Échec de la suppression par lot',
|
||||||
|
importToWebUi: 'Importer dans Web UI',
|
||||||
|
importSessionSuccess: 'Session importée dans Web UI',
|
||||||
|
importSessionAlreadyExists: 'La session existe déjà dans Web UI',
|
||||||
|
importSessionFailed: 'Échec de l’import de la session',
|
||||||
sessionDeleted: 'Session supprimee',
|
sessionDeleted: 'Session supprimee',
|
||||||
rename: 'Renommer',
|
rename: 'Renommer',
|
||||||
pin: 'Épingler',
|
pin: 'Épingler',
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export default {
|
|||||||
batchDeleteSuccess: '{count}件のセッションを削除しました',
|
batchDeleteSuccess: '{count}件のセッションを削除しました',
|
||||||
batchDeletePartial: '{failed}件の削除に失敗しました',
|
batchDeletePartial: '{failed}件の削除に失敗しました',
|
||||||
batchDeleteFailed: '一括削除に失敗しました',
|
batchDeleteFailed: '一括削除に失敗しました',
|
||||||
|
importToWebUi: 'Web UI にインポート',
|
||||||
|
importSessionSuccess: 'セッションを Web UI にインポートしました',
|
||||||
|
importSessionAlreadyExists: 'セッションは既に Web UI に存在します',
|
||||||
|
importSessionFailed: 'セッションのインポートに失敗しました',
|
||||||
sessionDeleted: 'セッションを削除しました',
|
sessionDeleted: 'セッションを削除しました',
|
||||||
rename: '名前変更',
|
rename: '名前変更',
|
||||||
pin: 'ピン留め',
|
pin: 'ピン留め',
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export default {
|
|||||||
batchDeleteSuccess: '{count}개의 세션을 삭제했습니다',
|
batchDeleteSuccess: '{count}개의 세션을 삭제했습니다',
|
||||||
batchDeletePartial: '{failed}개의 세션 삭제 실패',
|
batchDeletePartial: '{failed}개의 세션 삭제 실패',
|
||||||
batchDeleteFailed: '일괄 삭제 실패',
|
batchDeleteFailed: '일괄 삭제 실패',
|
||||||
|
importToWebUi: 'Web UI로 가져오기',
|
||||||
|
importSessionSuccess: '세션을 Web UI로 가져왔습니다',
|
||||||
|
importSessionAlreadyExists: '세션이 이미 Web UI에 있습니다',
|
||||||
|
importSessionFailed: '세션 가져오기 실패',
|
||||||
sessionDeleted: '세션이 삭제되었습니다',
|
sessionDeleted: '세션이 삭제되었습니다',
|
||||||
rename: '이름 변경',
|
rename: '이름 변경',
|
||||||
pin: '고정',
|
pin: '고정',
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export default {
|
|||||||
batchDeleteSuccess: '{count} sessões excluídas',
|
batchDeleteSuccess: '{count} sessões excluídas',
|
||||||
batchDeletePartial: '{failed} sessões falharam ao excluir',
|
batchDeletePartial: '{failed} sessões falharam ao excluir',
|
||||||
batchDeleteFailed: 'Falha na exclusão em lote',
|
batchDeleteFailed: 'Falha na exclusão em lote',
|
||||||
|
importToWebUi: 'Importar para Web UI',
|
||||||
|
importSessionSuccess: 'Sessão importada para Web UI',
|
||||||
|
importSessionAlreadyExists: 'A sessão já existe no Web UI',
|
||||||
|
importSessionFailed: 'Falha ao importar sessão',
|
||||||
sessionDeleted: 'Sessao excluida',
|
sessionDeleted: 'Sessao excluida',
|
||||||
rename: 'Renomear',
|
rename: 'Renomear',
|
||||||
pin: 'Fixar',
|
pin: 'Fixar',
|
||||||
|
|||||||
@@ -284,6 +284,10 @@ export default {
|
|||||||
batchDeleteSuccess: '已刪除 {count} 個工作階段',
|
batchDeleteSuccess: '已刪除 {count} 個工作階段',
|
||||||
batchDeletePartial: '{failed} 個工作階段刪除失敗',
|
batchDeletePartial: '{failed} 個工作階段刪除失敗',
|
||||||
batchDeleteFailed: '批次刪除失敗',
|
batchDeleteFailed: '批次刪除失敗',
|
||||||
|
importToWebUi: '匯入到 Web UI',
|
||||||
|
importSessionSuccess: '工作階段已匯入 Web UI',
|
||||||
|
importSessionAlreadyExists: '工作階段已存在於 Web UI',
|
||||||
|
importSessionFailed: '匯入工作階段失敗',
|
||||||
rename: '重新命名',
|
rename: '重新命名',
|
||||||
pin: '釘選',
|
pin: '釘選',
|
||||||
unpin: '取消釘選',
|
unpin: '取消釘選',
|
||||||
|
|||||||
@@ -286,6 +286,10 @@ export default {
|
|||||||
batchDeleteSuccess: '已删除 {count} 个会话',
|
batchDeleteSuccess: '已删除 {count} 个会话',
|
||||||
batchDeletePartial: '{failed} 个会话删除失败',
|
batchDeletePartial: '{failed} 个会话删除失败',
|
||||||
batchDeleteFailed: '批量删除失败',
|
batchDeleteFailed: '批量删除失败',
|
||||||
|
importToWebUi: '导入到 Web UI',
|
||||||
|
importSessionSuccess: '会话已导入 Web UI',
|
||||||
|
importSessionAlreadyExists: '会话已存在于 Web UI',
|
||||||
|
importSessionFailed: '导入会话失败',
|
||||||
rename: '重命名',
|
rename: '重命名',
|
||||||
pin: '置顶',
|
pin: '置顶',
|
||||||
unpin: '取消置顶',
|
unpin: '取消置顶',
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { type Session } from '@/stores/hermes/chat'
|
|||||||
import { useAppStore } from '@/stores/hermes/app'
|
import { useAppStore } from '@/stores/hermes/app'
|
||||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||||
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
||||||
import { NButton, NTooltip, useMessage } from 'naive-ui'
|
import { NButton, NDropdown, NPopconfirm, NTooltip, useMessage, type DropdownOption } from 'naive-ui'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { getSourceLabel } from '@/shared/session-display'
|
import { getSourceLabel } from '@/shared/session-display'
|
||||||
import { copyToClipboard } from '@/utils/clipboard'
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
|
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
|
||||||
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
|
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
|
||||||
import OutlinePanel from '@/components/hermes/chat/OutlinePanel.vue'
|
import OutlinePanel from '@/components/hermes/chat/OutlinePanel.vue'
|
||||||
import { deleteSession, fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions'
|
import { batchDeleteSessions, deleteSession, fetchHermesSessions, fetchHermesSession, importHermesSession, type SessionSummary } from '@/api/hermes/sessions'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const profilesStore = useProfilesStore()
|
const profilesStore = useProfilesStore()
|
||||||
@@ -42,6 +42,14 @@ const hermesSessionsLoaded = ref(false)
|
|||||||
const historySessionId = ref<string | null>(null)
|
const historySessionId = ref<string | null>(null)
|
||||||
const historySession = ref<Session | null>(null)
|
const historySession = ref<Session | null>(null)
|
||||||
const showOutline = ref(false)
|
const showOutline = ref(false)
|
||||||
|
const isBatchMode = ref(false)
|
||||||
|
const isBatchDeleting = ref(false)
|
||||||
|
const showBatchDeleteConfirm = ref(false)
|
||||||
|
const selectedSessionKeys = ref<Set<string>>(new Set())
|
||||||
|
const contextSessionId = ref<string | null>(null)
|
||||||
|
const showContextMenu = ref(false)
|
||||||
|
const contextMenuX = ref(0)
|
||||||
|
const contextMenuY = ref(0)
|
||||||
let hermesSessionsRequestId = 0
|
let hermesSessionsRequestId = 0
|
||||||
|
|
||||||
async function loadHermesSessions() {
|
async function loadHermesSessions() {
|
||||||
@@ -72,6 +80,28 @@ function findHistorySession(sessionId: string): SessionSummary | undefined {
|
|||||||
return hermesSessions.value.find(session => session.id === sessionId)
|
return hermesSessions.value.find(session => session.id === sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contextSessionSummary = computed(() =>
|
||||||
|
contextSessionId.value ? findHistorySession(contextSessionId.value) || null : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const contextSessionPinned = computed(() =>
|
||||||
|
contextSessionId.value ? sessionBrowserPrefsStore.isPinned(contextSessionId.value) : false,
|
||||||
|
)
|
||||||
|
|
||||||
|
const contextMenuOptions = computed<DropdownOption[]>(() => {
|
||||||
|
const options: DropdownOption[] = [
|
||||||
|
{
|
||||||
|
label: t('chat.importToWebUi'),
|
||||||
|
key: 'import-webui',
|
||||||
|
disabled: Boolean(contextSessionSummary.value?.webui_imported),
|
||||||
|
},
|
||||||
|
{ label: t(contextSessionPinned.value ? 'chat.unpin' : 'chat.pin'), key: 'pin' },
|
||||||
|
{ label: t('chat.copySessionLink'), key: 'copy-link' },
|
||||||
|
{ label: t('chat.copySessionId'), key: 'copy-id' },
|
||||||
|
]
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
async function loadHistorySession(sessionId: string, profile?: string | null) {
|
async function loadHistorySession(sessionId: string, profile?: string | null) {
|
||||||
const summary = findHistorySession(sessionId)
|
const summary = findHistorySession(sessionId)
|
||||||
const sessionProfile = profile || summary?.profile || null
|
const sessionProfile = profile || summary?.profile || null
|
||||||
@@ -252,6 +282,58 @@ const historySessions = computed<Session[]>(() =>
|
|||||||
hermesSessions.value.map(sessionSummaryToSession)
|
hermesSessions.value.map(sessionSummaryToSession)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function sessionSelectionKey(session: Pick<Session, 'id' | 'profile'>): string {
|
||||||
|
return `${session.profile || 'default'}\u0000${session.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBatchMode() {
|
||||||
|
if (isBatchDeleting.value) return
|
||||||
|
isBatchMode.value = !isBatchMode.value
|
||||||
|
if (!isBatchMode.value) {
|
||||||
|
selectedSessionKeys.value.clear()
|
||||||
|
showBatchDeleteConfirm.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSessionSelection(session: Session) {
|
||||||
|
if (isBatchDeleting.value) return
|
||||||
|
const key = sessionSelectionKey(session)
|
||||||
|
if (selectedSessionKeys.value.has(key)) {
|
||||||
|
selectedSessionKeys.value.delete(key)
|
||||||
|
} else {
|
||||||
|
selectedSessionKeys.value.add(key)
|
||||||
|
}
|
||||||
|
selectedSessionKeys.value = new Set(selectedSessionKeys.value)
|
||||||
|
if (selectedSessionKeys.value.size === 0) {
|
||||||
|
showBatchDeleteConfirm.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSessionSelected(session: Session): boolean {
|
||||||
|
return selectedSessionKeys.value.has(sessionSelectionKey(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAllSessions() {
|
||||||
|
if (isBatchDeleting.value) return
|
||||||
|
if (allSessionsSelected.value) {
|
||||||
|
selectedSessionKeys.value.clear()
|
||||||
|
selectedSessionKeys.value = new Set(selectedSessionKeys.value)
|
||||||
|
showBatchDeleteConfirm.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedSessionKeys.value.clear()
|
||||||
|
for (const session of historySessions.value) {
|
||||||
|
selectedSessionKeys.value.add(sessionSelectionKey(session))
|
||||||
|
}
|
||||||
|
selectedSessionKeys.value = new Set(selectedSessionKeys.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCount = computed(() => selectedSessionKeys.value.size)
|
||||||
|
const canSelectAll = computed(() => historySessions.value.length > 0)
|
||||||
|
const allSessionsSelected = computed(() =>
|
||||||
|
historySessions.value.length > 0 && selectedSessionKeys.value.size === historySessions.value.length
|
||||||
|
)
|
||||||
|
|
||||||
// Source sort order: api_server first, cron last, others alphabetical
|
// Source sort order: api_server first, cron last, others alphabetical
|
||||||
function sourceSortKey(source: string): number {
|
function sourceSortKey(source: string): number {
|
||||||
if (source === 'api_server') return -1
|
if (source === 'api_server') return -1
|
||||||
@@ -380,6 +462,47 @@ async function copySessionLink(id?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleContextMenu(e: MouseEvent, sessionId: string) {
|
||||||
|
e.preventDefault()
|
||||||
|
contextSessionId.value = sessionId
|
||||||
|
showContextMenu.value = true
|
||||||
|
contextMenuX.value = e.clientX
|
||||||
|
contextMenuY.value = e.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside() {
|
||||||
|
showContextMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportToWebUi(sessionId: string) {
|
||||||
|
const summary = findHistorySession(sessionId)
|
||||||
|
try {
|
||||||
|
const result = await importHermesSession(sessionId, summary?.profile || null)
|
||||||
|
if (result.ok) {
|
||||||
|
message.success(t(result.imported ? 'chat.importSessionSuccess' : 'chat.importSessionAlreadyExists'))
|
||||||
|
await loadHermesSessions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to the shared failure message.
|
||||||
|
}
|
||||||
|
message.error(t('chat.importSessionFailed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContextMenuSelect(key: string) {
|
||||||
|
showContextMenu.value = false
|
||||||
|
if (!contextSessionId.value) return
|
||||||
|
if (key === 'pin') {
|
||||||
|
sessionBrowserPrefsStore.togglePinned(contextSessionId.value)
|
||||||
|
} else if (key === 'copy-link') {
|
||||||
|
await copySessionLink(contextSessionId.value)
|
||||||
|
} else if (key === 'copy-id') {
|
||||||
|
await copySessionId(contextSessionId.value)
|
||||||
|
} else if (key === 'import-webui') {
|
||||||
|
await handleImportToWebUi(contextSessionId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteSession(id: string, profile?: string | null) {
|
async function handleDeleteSession(id: string, profile?: string | null) {
|
||||||
const summary = findHistorySession(id)
|
const summary = findHistorySession(id)
|
||||||
const sessionProfile = profile || summary?.profile || null
|
const sessionProfile = profile || summary?.profile || null
|
||||||
@@ -403,6 +526,58 @@ async function handleDeleteSession(id: string, profile?: string | null) {
|
|||||||
message.success(t('chat.sessionDeleted'))
|
message.success(t('chat.sessionDeleted'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
if (selectedSessionKeys.value.size === 0 || isBatchDeleting.value) return
|
||||||
|
|
||||||
|
const sessionsByKey = new Map(historySessions.value.map(session => [sessionSelectionKey(session), session]))
|
||||||
|
const targets = Array.from(selectedSessionKeys.value)
|
||||||
|
.map(key => sessionsByKey.get(key))
|
||||||
|
.filter((session): session is Session => Boolean(session))
|
||||||
|
.map(session => ({ id: session.id, profile: session.profile || null }))
|
||||||
|
if (targets.length === 0) return
|
||||||
|
|
||||||
|
const activeWasSelected = historySession.value
|
||||||
|
? selectedSessionKeys.value.has(sessionSelectionKey(historySession.value))
|
||||||
|
: false
|
||||||
|
|
||||||
|
isBatchDeleting.value = true
|
||||||
|
try {
|
||||||
|
const result = await batchDeleteSessions(targets)
|
||||||
|
if (result.deleted > 0) {
|
||||||
|
for (const target of targets) {
|
||||||
|
sessionBrowserPrefsStore.removePinned(target.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadHermesSessions()
|
||||||
|
|
||||||
|
if (activeWasSelected || (historySessionId.value && !findHistorySession(historySessionId.value))) {
|
||||||
|
historySessionId.value = null
|
||||||
|
historySession.value = null
|
||||||
|
await openDefaultHistorySession(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success(t('chat.batchDeleteSuccess', { count: result.deleted }))
|
||||||
|
if (result.failed > 0) {
|
||||||
|
message.warning(t('chat.batchDeletePartial', { failed: result.failed }))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error(t('chat.batchDeleteFailed'))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error(t('chat.batchDeleteFailed'))
|
||||||
|
} finally {
|
||||||
|
isBatchDeleting.value = false
|
||||||
|
showBatchDeleteConfirm.value = false
|
||||||
|
isBatchMode.value = false
|
||||||
|
selectedSessionKeys.value.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchDeleteConfirm() {
|
||||||
|
void handleBatchDelete()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -415,6 +590,66 @@ async function handleDeleteSession(id: string, profile?: string | null) {
|
|||||||
<button class="session-close-btn" @click="showSessions = false">
|
<button class="session-close-btn" @click="showSessions = false">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<NButton
|
||||||
|
v-if="!isBatchMode"
|
||||||
|
quaternary
|
||||||
|
size="tiny"
|
||||||
|
:disabled="hermesSessions.length === 0"
|
||||||
|
:title="t('chat.toggleBatchMode')"
|
||||||
|
@click="toggleBatchMode"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 11l3 3L22 4" />
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
v-if="isBatchMode"
|
||||||
|
quaternary
|
||||||
|
size="tiny"
|
||||||
|
:disabled="!canSelectAll || isBatchDeleting"
|
||||||
|
:title="allSessionsSelected ? t('common.cancel') : t('chat.selectAll')"
|
||||||
|
@click="toggleSelectAllSessions"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 11l3 3L22 4" />
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
<NPopconfirm
|
||||||
|
v-if="isBatchMode && selectedCount > 0"
|
||||||
|
v-model:show="showBatchDeleteConfirm"
|
||||||
|
:positive-button-props="{ loading: isBatchDeleting, disabled: isBatchDeleting }"
|
||||||
|
:negative-button-props="{ disabled: isBatchDeleting }"
|
||||||
|
@positive-click="handleBatchDeleteConfirm"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<NButton quaternary size="tiny" type="error" :loading="isBatchDeleting" :disabled="isBatchDeleting">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
{{ t('chat.confirmBatchDelete', { count: selectedCount }) }}
|
||||||
|
</NPopconfirm>
|
||||||
|
<NButton
|
||||||
|
v-if="isBatchMode"
|
||||||
|
quaternary
|
||||||
|
size="tiny"
|
||||||
|
:disabled="isBatchDeleting"
|
||||||
|
@click="toggleBatchMode"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showSessions" class="session-scope-note">
|
<div v-if="showSessions" class="session-scope-note">
|
||||||
@@ -437,9 +672,13 @@ async function handleDeleteSession(id: string, profile?: string | null) {
|
|||||||
:pinned="true"
|
:pinned="true"
|
||||||
:can-delete="true"
|
:can-delete="true"
|
||||||
:streaming="false"
|
:streaming="false"
|
||||||
|
:selectable="isBatchMode"
|
||||||
|
:selected="isSessionSelected(s)"
|
||||||
:show-profile="false"
|
:show-profile="false"
|
||||||
@select="handleSessionClick(s.id, s.profile)"
|
@select="isBatchMode ? toggleSessionSelection(s) : handleSessionClick(s.id, s.profile)"
|
||||||
|
@contextmenu="handleContextMenu($event, s.id)"
|
||||||
@delete="handleDeleteSession(s.id, s.profile)"
|
@delete="handleDeleteSession(s.id, s.profile)"
|
||||||
|
@toggle-select="toggleSessionSelection(s)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -458,15 +697,30 @@ async function handleDeleteSession(id: string, profile?: string | null) {
|
|||||||
:pinned="false"
|
:pinned="false"
|
||||||
:can-delete="true"
|
:can-delete="true"
|
||||||
:streaming="false"
|
:streaming="false"
|
||||||
|
:selectable="isBatchMode"
|
||||||
|
:selected="isSessionSelected(s)"
|
||||||
:show-profile="false"
|
:show-profile="false"
|
||||||
@select="handleSessionClick(s.id, s.profile)"
|
@select="isBatchMode ? toggleSessionSelection(s) : handleSessionClick(s.id, s.profile)"
|
||||||
|
@contextmenu="handleContextMenu($event, s.id)"
|
||||||
@delete="handleDeleteSession(s.id, s.profile)"
|
@delete="handleDeleteSession(s.id, s.profile)"
|
||||||
|
@toggle-select="toggleSessionSelection(s)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<NDropdown
|
||||||
|
placement="bottom-start"
|
||||||
|
trigger="manual"
|
||||||
|
:x="contextMenuX"
|
||||||
|
:y="contextMenuY"
|
||||||
|
:options="contextMenuOptions"
|
||||||
|
:show="showContextMenu"
|
||||||
|
@select="handleContextMenuSelect"
|
||||||
|
@clickoutside="handleClickOutside"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="chat-main">
|
<div class="chat-main">
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
@@ -490,16 +744,6 @@ async function handleDeleteSession(id: string, profile?: string | null) {
|
|||||||
</template>
|
</template>
|
||||||
{{ t('chat.outlineTitle') }}
|
{{ t('chat.outlineTitle') }}
|
||||||
</NTooltip>
|
</NTooltip>
|
||||||
<NTooltip trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<NButton quaternary size="small" @click="copySessionLink()" circle>
|
|
||||||
<template #icon>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
|
||||||
</template>
|
|
||||||
</NButton>
|
|
||||||
</template>
|
|
||||||
{{ t('chat.copySessionLink') }}
|
|
||||||
</NTooltip>
|
|
||||||
<NTooltip trigger="hover">
|
<NTooltip trigger="hover">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<NButton quaternary size="small" @click="copySessionId()" circle>
|
<NButton quaternary size="small" @click="copySessionId()" circle>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
getSessionDetail as localGetSessionDetail,
|
getSessionDetail as localGetSessionDetail,
|
||||||
deleteSession as localDeleteSession,
|
deleteSession as localDeleteSession,
|
||||||
renameSession as localRenameSession,
|
renameSession as localRenameSession,
|
||||||
|
createSession as localCreateSession,
|
||||||
|
addMessages as localAddMessages,
|
||||||
|
updateSession as localUpdateSession,
|
||||||
|
updateSessionStats as localUpdateSessionStats,
|
||||||
} from '../../db/hermes/session-store'
|
} from '../../db/hermes/session-store'
|
||||||
import { ExportCompressor } from '../../lib/context-compressor/export-compressor'
|
import { ExportCompressor } from '../../lib/context-compressor/export-compressor'
|
||||||
import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store'
|
import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store'
|
||||||
@@ -18,6 +22,7 @@ import { getGroupChatServer } from '../../routes/hermes/group-chat'
|
|||||||
import { logger } from '../../services/logger'
|
import { logger } from '../../services/logger'
|
||||||
import type { ConversationSummary } from '../../services/hermes/conversations'
|
import type { ConversationSummary } from '../../services/hermes/conversations'
|
||||||
import { listUserProfiles } from '../../db/hermes/users-store'
|
import { listUserProfiles } from '../../db/hermes/users-store'
|
||||||
|
import { readConfigYamlForProfile } from '../../services/config-helpers'
|
||||||
|
|
||||||
function getPendingDeletedSessionIds(): Set<string> {
|
function getPendingDeletedSessionIds(): Set<string> {
|
||||||
return getGroupChatServer()?.getStorage().getPendingDeletedSessionIds() || new Set<string>()
|
return getGroupChatServer()?.getStorage().getPendingDeletedSessionIds() || new Set<string>()
|
||||||
@@ -79,6 +84,26 @@ interface BatchDeleteTarget {
|
|||||||
profile?: string | null
|
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 {
|
function hasProfileOnDisk(profile: string): boolean {
|
||||||
return listProfileNamesFromDisk().includes(profile || 'default')
|
return listProfileNamesFromDisk().includes(profile || 'default')
|
||||||
}
|
}
|
||||||
@@ -109,6 +134,115 @@ async function deleteHermesSessionIfPresent(sessionId: string, profile?: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export async function listConversations(ctx: any) {
|
||||||
const source = (ctx.query.source as string) || undefined
|
const source = (ctx.query.source as string) || undefined
|
||||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||||
@@ -201,8 +335,12 @@ export async function listHermesSessions(ctx: any) {
|
|||||||
const profile = requestedProfile(ctx)
|
const profile = requestedProfile(ctx)
|
||||||
const effectiveLimit = limit && limit > 0 ? limit : 2000
|
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))
|
const allSessions = (await listSessionSummaries(source, effectiveLimit, profile))
|
||||||
.map(session => profile ? { ...session, profile } : session)
|
.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')) }
|
ctx.body = { sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s => s.source !== 'api_server')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +418,94 @@ export async function getHermesSession(ctx: any) {
|
|||||||
ctx.body = { session }
|
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) {
|
export async function remove(ctx: any) {
|
||||||
const sessionId = ctx.params.id
|
const sessionId = ctx.params.id
|
||||||
const existing = localGetSession(sessionId)
|
const existing = localGetSession(sessionId)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ sessionRoutes.get('/api/hermes/sessions/conversations/:id/messages/paginated', c
|
|||||||
sessionRoutes.get('/api/hermes/sessions', ctrl.list)
|
sessionRoutes.get('/api/hermes/sessions', ctrl.list)
|
||||||
sessionRoutes.get('/api/hermes/sessions/hermes', ctrl.listHermesSessions)
|
sessionRoutes.get('/api/hermes/sessions/hermes', ctrl.listHermesSessions)
|
||||||
sessionRoutes.get('/api/hermes/sessions/hermes/:id', ctrl.getHermesSession)
|
sessionRoutes.get('/api/hermes/sessions/hermes/:id', ctrl.getHermesSession)
|
||||||
|
sessionRoutes.post('/api/hermes/sessions/hermes/:id/import', ctrl.importHermesSession)
|
||||||
sessionRoutes.get('/api/hermes/search/sessions', ctrl.search)
|
sessionRoutes.get('/api/hermes/search/sessions', ctrl.search)
|
||||||
sessionRoutes.get('/api/hermes/sessions/search', ctrl.search)
|
sessionRoutes.get('/api/hermes/sessions/search', ctrl.search)
|
||||||
sessionRoutes.get('/api/hermes/sessions/usage', ctrl.usageBatch)
|
sessionRoutes.get('/api/hermes/sessions/usage', ctrl.usageBatch)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ vi.mock('@/router', () => ({
|
|||||||
import { getApiKey, setApiKey, clearApiKey, hasApiKey, getStoredUserRole, isStoredSuperAdmin, request } from '../../packages/client/src/api/client'
|
import { getApiKey, setApiKey, clearApiKey, hasApiKey, getStoredUserRole, isStoredSuperAdmin, request } from '../../packages/client/src/api/client'
|
||||||
import { getDownloadUrl } from '../../packages/client/src/api/hermes/download'
|
import { getDownloadUrl } from '../../packages/client/src/api/hermes/download'
|
||||||
import { uploadFiles } from '../../packages/client/src/api/hermes/files'
|
import { uploadFiles } from '../../packages/client/src/api/hermes/files'
|
||||||
import { batchDeleteSessions } from '../../packages/client/src/api/hermes/sessions'
|
import { batchDeleteSessions, importHermesSession } from '../../packages/client/src/api/hermes/sessions'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
|
||||||
function fakeJwt(payload: Record<string, unknown>) {
|
function fakeJwt(payload: Record<string, unknown>) {
|
||||||
@@ -230,5 +230,19 @@ describe('API Client', () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends the profile selector when importing a Hermes session', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ ok: true, imported: true }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await importHermesSession('cli-1', 'travel')
|
||||||
|
|
||||||
|
const [url, options] = mockFetch.mock.calls[0]
|
||||||
|
expect(url).toBe('/api/hermes/sessions/hermes/cli-1/import?profile=travel')
|
||||||
|
expect(options.method).toBe('POST')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const listConversationSummariesFromDbMock = vi.fn()
|
|||||||
const getConversationDetailFromDbMock = vi.fn()
|
const getConversationDetailFromDbMock = vi.fn()
|
||||||
const listConversationSummariesMock = vi.fn()
|
const listConversationSummariesMock = vi.fn()
|
||||||
const getConversationDetailMock = vi.fn()
|
const getConversationDetailMock = vi.fn()
|
||||||
|
const listSessionSummariesMock = vi.fn()
|
||||||
const getSessionDetailFromDbMock = vi.fn()
|
const getSessionDetailFromDbMock = vi.fn()
|
||||||
const getSessionDetailFromDbWithProfileMock = vi.fn()
|
const getSessionDetailFromDbWithProfileMock = vi.fn()
|
||||||
const getExactSessionDetailFromDbWithProfileMock = vi.fn()
|
const getExactSessionDetailFromDbWithProfileMock = vi.fn()
|
||||||
@@ -17,12 +18,15 @@ const localDeleteSessionMock = vi.fn()
|
|||||||
const localRenameSessionMock = vi.fn()
|
const localRenameSessionMock = vi.fn()
|
||||||
const localCreateSessionMock = vi.fn()
|
const localCreateSessionMock = vi.fn()
|
||||||
const localUpdateSessionMock = vi.fn()
|
const localUpdateSessionMock = vi.fn()
|
||||||
|
const localAddMessagesMock = vi.fn()
|
||||||
|
const localUpdateSessionStatsMock = vi.fn()
|
||||||
const getGroupChatServerMock = vi.fn()
|
const getGroupChatServerMock = vi.fn()
|
||||||
const getLocalUsageStatsMock = vi.fn()
|
const getLocalUsageStatsMock = vi.fn()
|
||||||
const getActiveProfileNameMock = vi.fn()
|
const getActiveProfileNameMock = vi.fn()
|
||||||
const loggerWarnMock = vi.fn()
|
const loggerWarnMock = vi.fn()
|
||||||
const getCompressionSnapshotMock = vi.fn()
|
const getCompressionSnapshotMock = vi.fn()
|
||||||
const listUserProfilesMock = vi.fn()
|
const listUserProfilesMock = vi.fn()
|
||||||
|
const readConfigYamlForProfileMock = vi.fn()
|
||||||
|
|
||||||
vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({
|
vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({
|
||||||
listConversationSummariesFromDb: listConversationSummariesFromDbMock,
|
listConversationSummariesFromDb: listConversationSummariesFromDbMock,
|
||||||
@@ -50,7 +54,7 @@ vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
||||||
listSessionSummaries: vi.fn(),
|
listSessionSummaries: listSessionSummariesMock,
|
||||||
searchSessionSummaries: vi.fn(),
|
searchSessionSummaries: vi.fn(),
|
||||||
getSessionDetailFromDb: getSessionDetailFromDbMock,
|
getSessionDetailFromDb: getSessionDetailFromDbMock,
|
||||||
getSessionDetailFromDbWithProfile: getSessionDetailFromDbWithProfileMock,
|
getSessionDetailFromDbWithProfile: getSessionDetailFromDbWithProfileMock,
|
||||||
@@ -65,8 +69,10 @@ vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
|
|||||||
deleteSession: localDeleteSessionMock,
|
deleteSession: localDeleteSessionMock,
|
||||||
renameSession: localRenameSessionMock,
|
renameSession: localRenameSessionMock,
|
||||||
createSession: localCreateSessionMock,
|
createSession: localCreateSessionMock,
|
||||||
|
addMessages: localAddMessagesMock,
|
||||||
getSession: getSessionMock,
|
getSession: getSessionMock,
|
||||||
updateSession: localUpdateSessionMock,
|
updateSession: localUpdateSessionMock,
|
||||||
|
updateSessionStats: localUpdateSessionStatsMock,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
|
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
|
||||||
@@ -93,6 +99,10 @@ vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
|||||||
listProfileNamesFromDisk: () => ['default', 'travel'],
|
listProfileNamesFromDisk: () => ['default', 'travel'],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||||
|
readConfigYamlForProfile: readConfigYamlForProfileMock,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
|
vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
|
||||||
getCompressionSnapshot: getCompressionSnapshotMock,
|
getCompressionSnapshot: getCompressionSnapshotMock,
|
||||||
}))
|
}))
|
||||||
@@ -115,6 +125,7 @@ describe('session conversations controller', () => {
|
|||||||
getConversationDetailFromDbMock.mockReset()
|
getConversationDetailFromDbMock.mockReset()
|
||||||
listConversationSummariesMock.mockReset()
|
listConversationSummariesMock.mockReset()
|
||||||
getConversationDetailMock.mockReset()
|
getConversationDetailMock.mockReset()
|
||||||
|
listSessionSummariesMock.mockReset()
|
||||||
getSessionDetailFromDbMock.mockReset()
|
getSessionDetailFromDbMock.mockReset()
|
||||||
getSessionDetailFromDbWithProfileMock.mockReset()
|
getSessionDetailFromDbWithProfileMock.mockReset()
|
||||||
getExactSessionDetailFromDbWithProfileMock.mockReset()
|
getExactSessionDetailFromDbWithProfileMock.mockReset()
|
||||||
@@ -128,6 +139,8 @@ describe('session conversations controller', () => {
|
|||||||
localRenameSessionMock.mockReset()
|
localRenameSessionMock.mockReset()
|
||||||
localCreateSessionMock.mockReset()
|
localCreateSessionMock.mockReset()
|
||||||
localUpdateSessionMock.mockReset()
|
localUpdateSessionMock.mockReset()
|
||||||
|
localAddMessagesMock.mockReset()
|
||||||
|
localUpdateSessionStatsMock.mockReset()
|
||||||
getGroupChatServerMock.mockReset()
|
getGroupChatServerMock.mockReset()
|
||||||
getGroupChatServerMock.mockReturnValue(null)
|
getGroupChatServerMock.mockReturnValue(null)
|
||||||
getLocalUsageStatsMock.mockReset()
|
getLocalUsageStatsMock.mockReset()
|
||||||
@@ -137,6 +150,8 @@ describe('session conversations controller', () => {
|
|||||||
getCompressionSnapshotMock.mockReset()
|
getCompressionSnapshotMock.mockReset()
|
||||||
listUserProfilesMock.mockReset()
|
listUserProfilesMock.mockReset()
|
||||||
listUserProfilesMock.mockReturnValue([])
|
listUserProfilesMock.mockReturnValue([])
|
||||||
|
readConfigYamlForProfileMock.mockReset()
|
||||||
|
readConfigYamlForProfileMock.mockResolvedValue({ model: { default: 'gpt-default', provider: 'openai' } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lists conversations from the local session store', async () => {
|
it('lists conversations from the local session store', async () => {
|
||||||
@@ -272,6 +287,66 @@ describe('session conversations controller', () => {
|
|||||||
expect(localListSessionsMock).toHaveBeenCalledWith('travel', undefined, 2000)
|
expect(localListSessionsMock).toHaveBeenCalledWith('travel', undefined, 2000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('marks Hermes history sessions that already exist in the Web UI store', async () => {
|
||||||
|
localListSessionsMock.mockReturnValue([{ id: 'cli-1', profile: 'travel' }])
|
||||||
|
listSessionSummariesMock.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'cli-1',
|
||||||
|
source: 'cli',
|
||||||
|
model: 'gpt-5',
|
||||||
|
title: 'Imported',
|
||||||
|
started_at: 1,
|
||||||
|
ended_at: null,
|
||||||
|
last_active: 2,
|
||||||
|
message_count: 1,
|
||||||
|
tool_call_count: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
reasoning_tokens: 0,
|
||||||
|
billing_provider: null,
|
||||||
|
estimated_cost_usd: 0,
|
||||||
|
actual_cost_usd: null,
|
||||||
|
cost_status: '',
|
||||||
|
preview: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cli-2',
|
||||||
|
source: 'cli',
|
||||||
|
model: 'gpt-5',
|
||||||
|
title: 'History only',
|
||||||
|
started_at: 1,
|
||||||
|
ended_at: null,
|
||||||
|
last_active: 2,
|
||||||
|
message_count: 1,
|
||||||
|
tool_call_count: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
reasoning_tokens: 0,
|
||||||
|
billing_provider: null,
|
||||||
|
estimated_cost_usd: 0,
|
||||||
|
actual_cost_usd: null,
|
||||||
|
cost_status: '',
|
||||||
|
preview: '',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||||
|
const ctx: any = { query: { profile: 'travel' }, state: {}, body: null }
|
||||||
|
|
||||||
|
await mod.listHermesSessions(ctx)
|
||||||
|
|
||||||
|
expect(localListSessionsMock).toHaveBeenCalledWith('travel', undefined, 2000)
|
||||||
|
expect(listSessionSummariesMock).toHaveBeenCalledWith(undefined, 2000, 'travel')
|
||||||
|
expect(ctx.body.sessions).toEqual([
|
||||||
|
expect.objectContaining({ id: 'cli-1', profile: 'travel', webui_imported: true }),
|
||||||
|
expect.objectContaining({ id: 'cli-2', profile: 'travel', webui_imported: false }),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('searches all account-accessible single-chat sessions unless profile is explicit', async () => {
|
it('searches all account-accessible single-chat sessions unless profile is explicit', async () => {
|
||||||
localSearchSessionsMock.mockReturnValue([])
|
localSearchSessionsMock.mockReturnValue([])
|
||||||
|
|
||||||
@@ -637,6 +712,80 @@ describe('session conversations controller', () => {
|
|||||||
expect(ctx.body).toMatchObject({ ok: true, deleted: 2, failed: 0, hermesDeleted: 2 })
|
expect(ctx.body).toMatchObject({ ok: true, deleted: 2, failed: 0, hermesDeleted: 2 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('imports a Hermes session into the local Web UI store', async () => {
|
||||||
|
const hermesDetail = {
|
||||||
|
id: 'cli-1',
|
||||||
|
source: 'cli',
|
||||||
|
user_id: null,
|
||||||
|
model: 'gpt-5',
|
||||||
|
title: 'CLI run',
|
||||||
|
started_at: 100,
|
||||||
|
ended_at: 200,
|
||||||
|
end_reason: null,
|
||||||
|
message_count: 2,
|
||||||
|
tool_call_count: 0,
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 20,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
reasoning_tokens: 0,
|
||||||
|
billing_provider: null,
|
||||||
|
estimated_cost_usd: 0,
|
||||||
|
actual_cost_usd: null,
|
||||||
|
cost_status: '',
|
||||||
|
preview: 'hello',
|
||||||
|
last_active: 200,
|
||||||
|
thread_session_count: 1,
|
||||||
|
messages: [
|
||||||
|
{ id: 1, session_id: 'cli-1', role: 'user', content: 'hello', tool_call_id: null, tool_calls: null, tool_name: null, timestamp: 100, token_count: null, finish_reason: null, reasoning: null },
|
||||||
|
{ id: 2, session_id: 'cli-1', role: 'assistant', content: 'hi', tool_call_id: null, tool_calls: null, tool_name: null, timestamp: 101, token_count: null, finish_reason: null, reasoning: null, reasoning_details: { text: 'ok' } },
|
||||||
|
{ id: 3, session_id: 'cli-1', role: 'assistant', content: '', tool_call_id: null, tool_calls: [{ id: 'call-1', function: { name: 'read_file', arguments: { path: 'README.md' } } }], tool_name: null, timestamp: 102, token_count: null, finish_reason: 'tool_calls', reasoning: null },
|
||||||
|
{ id: 4, session_id: 'cli-1', role: 'tool', content: { ok: true }, tool_call_id: 'call-1', tool_calls: null, tool_name: 'read_file', timestamp: 103, token_count: null, finish_reason: null, reasoning: null },
|
||||||
|
{ id: 5, session_id: 'cli-1', role: 'tool', content: 'orphan', tool_call_id: null, tool_calls: null, tool_name: 'bad_tool', timestamp: 104, token_count: null, finish_reason: null, reasoning: null },
|
||||||
|
{ id: 6, session_id: 'cli-1', role: 'system', content: 'drop me', tool_call_id: null, tool_calls: null, tool_name: null, timestamp: 105, token_count: null, finish_reason: null, reasoning: null },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
localGetSessionDetailMock.mockReturnValueOnce(null).mockReturnValueOnce({ ...hermesDetail, profile: 'travel' })
|
||||||
|
getSessionDetailFromDbWithProfileMock.mockResolvedValue(hermesDetail)
|
||||||
|
|
||||||
|
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||||
|
const ctx: any = { params: { id: 'cli-1' }, query: { profile: 'travel' }, state: {}, body: null }
|
||||||
|
|
||||||
|
await mod.importHermesSession(ctx)
|
||||||
|
|
||||||
|
expect(getSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('cli-1', 'travel')
|
||||||
|
expect(localCreateSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
id: 'cli-1',
|
||||||
|
profile: 'travel',
|
||||||
|
source: 'cli',
|
||||||
|
model: 'gpt-default',
|
||||||
|
provider: 'openai',
|
||||||
|
title: 'CLI run',
|
||||||
|
}))
|
||||||
|
expect(localUpdateSessionMock).toHaveBeenCalledWith('cli-1', expect.objectContaining({
|
||||||
|
source: 'cli',
|
||||||
|
model: 'gpt-default',
|
||||||
|
provider: 'openai',
|
||||||
|
}))
|
||||||
|
expect(localAddMessagesMock).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ session_id: 'cli-1', role: 'user', content: 'hello', tool_calls: null }),
|
||||||
|
expect.objectContaining({ session_id: 'cli-1', role: 'assistant', content: 'hi', reasoning_details: '{"text":"ok"}' }),
|
||||||
|
expect.objectContaining({
|
||||||
|
session_id: 'cli-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'read_file', arguments: '{"path":"README.md"}' } }],
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ session_id: 'cli-1', role: 'tool', content: '{"ok":true}', tool_call_id: 'call-1', tool_name: 'read_file' }),
|
||||||
|
])
|
||||||
|
expect(localUpdateSessionStatsMock).toHaveBeenCalledWith('cli-1')
|
||||||
|
expect(localUpdateSessionMock.mock.calls.at(-1)?.[1]).toEqual(expect.objectContaining({
|
||||||
|
last_active: expect.any(Number),
|
||||||
|
}))
|
||||||
|
expect(localUpdateSessionMock.mock.calls.at(-1)?.[1].last_active).toBeGreaterThan(200)
|
||||||
|
expect(ctx.body).toMatchObject({ ok: true, imported: true })
|
||||||
|
})
|
||||||
|
|
||||||
describe('exportSession', () => {
|
describe('exportSession', () => {
|
||||||
it('returns session as JSON download with correct headers (full mode)', async () => {
|
it('returns session as JSON download with correct headers (full mode)', async () => {
|
||||||
const sessionData = { id: 'abc-123', title: 'Test Session', messages: [{ id: 1, role: 'user', content: 'hello' }] }
|
const sessionData = { id: 'abc-123', title: 'Test Session', messages: [{ id: 1, role: 'user', content: 'hello' }] }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const getConversationMessagesPaginatedMock = vi.fn(async (ctx: any) => { ctx.bod
|
|||||||
const listMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 's1' }] } })
|
const listMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 's1' }] } })
|
||||||
const listHermesSessionsMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 'hermes-1' }] } })
|
const listHermesSessionsMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 'hermes-1' }] } })
|
||||||
const getHermesSessionMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
|
const getHermesSessionMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
|
||||||
|
const importHermesSessionMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id } })
|
||||||
const searchMock = vi.fn(async (ctx: any) => { ctx.body = { results: [{ id: 'search-1' }] } })
|
const searchMock = vi.fn(async (ctx: any) => { ctx.body = { results: [{ id: 'search-1' }] } })
|
||||||
const getMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
|
const getMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
|
||||||
const removeMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
|
const removeMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
|
||||||
@@ -27,6 +28,7 @@ vi.mock('../../packages/server/src/controllers/hermes/sessions', () => ({
|
|||||||
list: listMock,
|
list: listMock,
|
||||||
listHermesSessions: listHermesSessionsMock,
|
listHermesSessions: listHermesSessionsMock,
|
||||||
getHermesSession: getHermesSessionMock,
|
getHermesSession: getHermesSessionMock,
|
||||||
|
importHermesSession: importHermesSessionMock,
|
||||||
search: searchMock,
|
search: searchMock,
|
||||||
get: getMock,
|
get: getMock,
|
||||||
remove: removeMock,
|
remove: removeMock,
|
||||||
@@ -49,6 +51,9 @@ describe('session routes', () => {
|
|||||||
getConversationMessagesMock.mockClear()
|
getConversationMessagesMock.mockClear()
|
||||||
getConversationMessagesPaginatedMock.mockClear()
|
getConversationMessagesPaginatedMock.mockClear()
|
||||||
listMock.mockClear()
|
listMock.mockClear()
|
||||||
|
listHermesSessionsMock.mockClear()
|
||||||
|
getHermesSessionMock.mockClear()
|
||||||
|
importHermesSessionMock.mockClear()
|
||||||
searchMock.mockClear()
|
searchMock.mockClear()
|
||||||
getMock.mockClear()
|
getMock.mockClear()
|
||||||
removeMock.mockClear()
|
removeMock.mockClear()
|
||||||
@@ -65,6 +70,9 @@ describe('session routes', () => {
|
|||||||
'/api/hermes/sessions/conversations/:id/messages',
|
'/api/hermes/sessions/conversations/:id/messages',
|
||||||
'/api/hermes/sessions/conversations/:id/messages/paginated',
|
'/api/hermes/sessions/conversations/:id/messages/paginated',
|
||||||
'/api/hermes/sessions',
|
'/api/hermes/sessions',
|
||||||
|
'/api/hermes/sessions/hermes',
|
||||||
|
'/api/hermes/sessions/hermes/:id',
|
||||||
|
'/api/hermes/sessions/hermes/:id/import',
|
||||||
'/api/hermes/search/sessions',
|
'/api/hermes/search/sessions',
|
||||||
'/api/hermes/sessions/search',
|
'/api/hermes/sessions/search',
|
||||||
'/api/hermes/sessions/usage',
|
'/api/hermes/sessions/usage',
|
||||||
@@ -118,6 +126,18 @@ describe('session routes', () => {
|
|||||||
expect(detailCtx.body).toEqual({ session_id: 'child-session', messages: [] })
|
expect(detailCtx.body).toEqual({ session_id: 'child-session', messages: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('delegates Hermes session import to the controller', async () => {
|
||||||
|
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||||
|
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/hermes/:id/import')
|
||||||
|
const handler = layer.stack[0]
|
||||||
|
const ctx: any = { params: { id: 'hermes-abc' }, query: {}, request: { body: { profile: 'default' } }, body: null }
|
||||||
|
|
||||||
|
await handler(ctx)
|
||||||
|
|
||||||
|
expect(importHermesSessionMock).toHaveBeenCalledWith(ctx)
|
||||||
|
expect(ctx.body).toEqual({ session_id: 'hermes-abc' })
|
||||||
|
})
|
||||||
|
|
||||||
it('delegates session export to the controller', async () => {
|
it('delegates session export to the controller', async () => {
|
||||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||||
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/export')
|
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/:id/export')
|
||||||
|
|||||||
Reference in New Issue
Block a user