feat: add bridge session commands (#743)
This commit is contained in:
@@ -77,6 +77,7 @@ const sessionEventHandlers = new Map<string, {
|
|||||||
onAbortStarted: (event: RunEvent) => void
|
onAbortStarted: (event: RunEvent) => void
|
||||||
onAbortCompleted: (event: RunEvent) => void
|
onAbortCompleted: (event: RunEvent) => void
|
||||||
onUsageUpdated: (event: RunEvent) => void
|
onUsageUpdated: (event: RunEvent) => void
|
||||||
|
onSessionCommand?: (event: RunEvent) => void
|
||||||
onRunQueued?: (event: RunEvent) => void
|
onRunQueued?: (event: RunEvent) => void
|
||||||
onApprovalRequested?: (event: RunEvent) => void
|
onApprovalRequested?: (event: RunEvent) => void
|
||||||
onApprovalResolved?: (event: RunEvent) => void
|
onApprovalResolved?: (event: RunEvent) => void
|
||||||
@@ -291,6 +292,16 @@ function globalUsageUpdatedHandler(event: RunEvent): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function globalSessionCommandHandler(event: RunEvent): void {
|
||||||
|
const sid = event.session_id
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const handlers = sessionEventHandlers.get(sid)
|
||||||
|
if (handlers?.onSessionCommand) {
|
||||||
|
handlers.onSessionCommand(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function globalApprovalRequestedHandler(event: RunEvent): void {
|
function globalApprovalRequestedHandler(event: RunEvent): void {
|
||||||
const sid = event.session_id
|
const sid = event.session_id
|
||||||
if (!sid) return
|
if (!sid) return
|
||||||
@@ -334,6 +345,7 @@ export function registerSessionHandlers(
|
|||||||
onAbortStarted: (event: RunEvent) => void
|
onAbortStarted: (event: RunEvent) => void
|
||||||
onAbortCompleted: (event: RunEvent) => void
|
onAbortCompleted: (event: RunEvent) => void
|
||||||
onUsageUpdated: (event: RunEvent) => void
|
onUsageUpdated: (event: RunEvent) => void
|
||||||
|
onSessionCommand?: (event: RunEvent) => void
|
||||||
onRunQueued?: (event: RunEvent) => void
|
onRunQueued?: (event: RunEvent) => void
|
||||||
onApprovalRequested?: (event: RunEvent) => void
|
onApprovalRequested?: (event: RunEvent) => void
|
||||||
onApprovalResolved?: (event: RunEvent) => void
|
onApprovalResolved?: (event: RunEvent) => void
|
||||||
@@ -436,6 +448,7 @@ export function connectChatRun(): Socket {
|
|||||||
|
|
||||||
// Usage events
|
// Usage events
|
||||||
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
|
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
|
||||||
|
chatRunSocket.on('session.command', globalSessionCommandHandler)
|
||||||
|
|
||||||
globalListenersRegistered = true
|
globalListenersRegistered = true
|
||||||
}
|
}
|
||||||
@@ -565,6 +578,14 @@ export function startRunViaSocket(
|
|||||||
if (closed) return
|
if (closed) return
|
||||||
onEvent(evt)
|
onEvent(evt)
|
||||||
},
|
},
|
||||||
|
onSessionCommand: (evt: RunEvent) => {
|
||||||
|
if (closed) return
|
||||||
|
onEvent(evt)
|
||||||
|
if ((evt as any).terminal === false) return
|
||||||
|
closed = true
|
||||||
|
sessionEventHandlers.delete(sid)
|
||||||
|
onDone()
|
||||||
|
},
|
||||||
onRunQueued: (evt: RunEvent) => {
|
onRunQueued: (evt: RunEvent) => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
onEvent(evt)
|
onEvent(evt)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export interface SessionSearchResult extends SessionSummary {
|
|||||||
export interface HermesMessage {
|
export interface HermesMessage {
|
||||||
id: number
|
id: number
|
||||||
session_id: string
|
session_id: string
|
||||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
role: 'user' | 'assistant' | 'system' | 'tool' | 'command'
|
||||||
content: string
|
content: string
|
||||||
tool_call_id: string | null
|
tool_call_id: string | null
|
||||||
tool_calls: any[] | null
|
tool_calls: any[] | null
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useProfilesStore } from '@/stores/hermes/profiles'
|
|||||||
import { fetchContextLength } from '@/api/hermes/sessions'
|
import { fetchContextLength } from '@/api/hermes/sessions'
|
||||||
import { setModelContext } from '@/api/hermes/model-context'
|
import { setModelContext } from '@/api/hermes/model-context'
|
||||||
import { NButton, NTooltip, NSwitch, NModal, NInputNumber, useMessage } from 'naive-ui'
|
import { NButton, NTooltip, NSwitch, NModal, NInputNumber, useMessage } from 'naive-ui'
|
||||||
import { computed, ref, onMounted, watch } from 'vue'
|
import { computed, ref, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
@@ -14,12 +14,37 @@ const { t } = useI18n()
|
|||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const textareaRef = ref<HTMLTextAreaElement>()
|
const textareaRef = ref<HTMLTextAreaElement>()
|
||||||
|
const commandDropdownRef = ref<HTMLDivElement>()
|
||||||
const fileInputRef = ref<HTMLInputElement>()
|
const fileInputRef = ref<HTMLInputElement>()
|
||||||
const attachments = ref<Attachment[]>([])
|
const attachments = ref<Attachment[]>([])
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
const isComposing = ref(false)
|
const isComposing = ref(false)
|
||||||
|
|
||||||
|
const bridgeCommands = computed(() => [
|
||||||
|
{ name: 'usage', args: '', description: t('chat.slashCommands.usage') },
|
||||||
|
{ name: 'status', args: '', description: t('chat.slashCommands.status') },
|
||||||
|
{ name: 'abort', args: '', description: t('chat.slashCommands.abort') },
|
||||||
|
{ name: 'queue', args: t('chat.slashCommandArgs.message'), description: t('chat.slashCommands.queue') },
|
||||||
|
{ name: 'clear', args: '', description: t('chat.slashCommands.clear') },
|
||||||
|
{ name: 'clear', args: '--history', insertText: 'clear --history', description: t('chat.slashCommands.clearHistory') },
|
||||||
|
{ name: 'title', args: t('chat.slashCommandArgs.title'), description: t('chat.slashCommands.title') },
|
||||||
|
{ name: 'compress', args: '', description: t('chat.slashCommands.compress') },
|
||||||
|
{ name: 'steer', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.steer') },
|
||||||
|
{ name: 'destroy', args: '', description: t('chat.slashCommands.destroy') },
|
||||||
|
])
|
||||||
|
|
||||||
|
const slashActive = ref(false)
|
||||||
|
const slashQuery = ref('')
|
||||||
|
const slashActiveIndex = ref(0)
|
||||||
|
const isBridgeSession = computed(() => chatStore.activeSession?.source === 'cli')
|
||||||
|
const filteredBridgeCommands = computed(() => {
|
||||||
|
const query = slashQuery.value.toLowerCase()
|
||||||
|
return bridgeCommands.value.filter(command =>
|
||||||
|
command.name.includes(query) || command.insertText?.includes(query),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// 自定义高度拖拽
|
// 自定义高度拖拽
|
||||||
const textareaHeight = ref<number | null>(null) // null = auto
|
const textareaHeight = ref<number | null>(null) // null = auto
|
||||||
|
|
||||||
@@ -73,6 +98,44 @@ watch(autoPlaySpeech, (value) => {
|
|||||||
|
|
||||||
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
|
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
|
||||||
|
|
||||||
|
function scrollCommandIntoView() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (!commandDropdownRef.value) return
|
||||||
|
const active = commandDropdownRef.value.querySelector('.active') as HTMLElement | null
|
||||||
|
active?.scrollIntoView({ block: 'nearest', behavior: 'instant' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlashState() {
|
||||||
|
if (!isBridgeSession.value) {
|
||||||
|
slashActive.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const el = textareaRef.value
|
||||||
|
if (!el) return
|
||||||
|
const cursorPos = el.selectionStart
|
||||||
|
const beforeCursor = inputText.value.slice(0, cursorPos)
|
||||||
|
if (!beforeCursor.startsWith('/') || beforeCursor.includes(' ') || beforeCursor.includes('\n')) {
|
||||||
|
slashActive.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slashQuery.value = beforeCursor.slice(1)
|
||||||
|
slashActiveIndex.value = 0
|
||||||
|
slashActive.value = filteredBridgeCommands.value.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBridgeCommand(command: { name: string; args: string; insertText?: string }) {
|
||||||
|
inputText.value = `/${command.insertText || command.name} `
|
||||||
|
slashActive.value = false
|
||||||
|
nextTick(() => {
|
||||||
|
const el = textareaRef.value
|
||||||
|
if (!el) return
|
||||||
|
const pos = inputText.value.length
|
||||||
|
el.setSelectionRange(pos, pos)
|
||||||
|
el.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- Context info ---
|
// --- Context info ---
|
||||||
|
|
||||||
const contextLength = ref(200000)
|
const contextLength = ref(200000)
|
||||||
@@ -231,6 +294,7 @@ function handleSend() {
|
|||||||
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
|
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
attachments.value = []
|
attachments.value = []
|
||||||
|
slashActive.value = false
|
||||||
|
|
||||||
if (textareaRef.value) {
|
if (textareaRef.value) {
|
||||||
textareaRef.value.style.height = 'auto'
|
textareaRef.value.style.height = 'auto'
|
||||||
@@ -244,6 +308,7 @@ function handleCompositionStart() {
|
|||||||
function handleCompositionEnd() {
|
function handleCompositionEnd() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
isComposing.value = false
|
isComposing.value = false
|
||||||
|
updateSlashState()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +317,31 @@ function isImeEnter(e: KeyboardEvent): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (slashActive.value && filteredBridgeCommands.value.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
slashActiveIndex.value = (slashActiveIndex.value + 1) % filteredBridgeCommands.value.length
|
||||||
|
scrollCommandIntoView()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
slashActiveIndex.value = (slashActiveIndex.value - 1 + filteredBridgeCommands.value.length) % filteredBridgeCommands.value.length
|
||||||
|
scrollCommandIntoView()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
selectBridgeCommand(filteredBridgeCommands.value[slashActiveIndex.value])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
slashActive.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key !== 'Enter' || e.shiftKey) return
|
if (e.key !== 'Enter' || e.shiftKey) return
|
||||||
if (isImeEnter(e)) return
|
if (isImeEnter(e)) return
|
||||||
|
|
||||||
@@ -260,13 +350,34 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleInput(e: Event) {
|
function handleInput(e: Event) {
|
||||||
|
const el = e.target as HTMLTextAreaElement
|
||||||
|
if (!isComposing.value) updateSlashState()
|
||||||
// 用户手动拖拽自定义高度时,不覆盖
|
// 用户手动拖拽自定义高度时,不覆盖
|
||||||
if (textareaHeight.value !== null) return
|
if (textareaHeight.value !== null) return
|
||||||
const el = e.target as HTMLTextAreaElement
|
|
||||||
el.style.height = 'auto'
|
el.style.height = 'auto'
|
||||||
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCommandHover(index: number) {
|
||||||
|
slashActiveIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentMousedown(e: MouseEvent) {
|
||||||
|
if (!slashActive.value) return
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest('.slash-command-dropdown') && !target.closest('.input-wrapper')) {
|
||||||
|
slashActive.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousedown', onDocumentMousedown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', onDocumentMousedown)
|
||||||
|
})
|
||||||
|
|
||||||
function removeAttachment(id: string) {
|
function removeAttachment(id: string) {
|
||||||
const idx = attachments.value.findIndex(a => a.id === id)
|
const idx = attachments.value.findIndex(a => a.id === id)
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
@@ -396,6 +507,26 @@ function isImage(type: string): boolean {
|
|||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@paste="handlePaste"
|
@paste="handlePaste"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<Transition name="dropdown-fade">
|
||||||
|
<div
|
||||||
|
v-if="slashActive && filteredBridgeCommands.length > 0"
|
||||||
|
ref="commandDropdownRef"
|
||||||
|
class="slash-command-dropdown"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(command, i) in filteredBridgeCommands"
|
||||||
|
:key="command.name"
|
||||||
|
class="slash-command-item"
|
||||||
|
:class="{ active: i === slashActiveIndex }"
|
||||||
|
@mousedown.prevent="selectBridgeCommand(command)"
|
||||||
|
@mouseenter="handleCommandHover(i)"
|
||||||
|
>
|
||||||
|
<span class="slash-command-name">/{{ command.name }}</span>
|
||||||
|
<span v-if="command.args" class="slash-command-args">{{ command.args }}</span>
|
||||||
|
<span class="slash-command-desc">{{ command.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
<div class="input-actions">
|
<div class="input-actions">
|
||||||
<NButton
|
<NButton
|
||||||
v-if="chatStore.isStreaming"
|
v-if="chatStore.isStreaming"
|
||||||
@@ -685,6 +816,75 @@ function isImage(type: string): boolean {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slash-command-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $bg-primary;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
|
||||||
|
z-index: 20;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 36px;
|
||||||
|
|
||||||
|
&.active,
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-name {
|
||||||
|
font-family: $font-code;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $accent-primary;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-args {
|
||||||
|
font-family: $font-code;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-muted;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-command-desc {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-fade-enter-active,
|
||||||
|
.dropdown-fade-leave-active {
|
||||||
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-fade-enter-from,
|
||||||
|
.dropdown-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
// Drag-over state
|
// Drag-over state
|
||||||
.input-wrapper.drag-over {
|
.input-wrapper.drag-over {
|
||||||
border-color: var(--accent-info);
|
border-color: var(--accent-info);
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ const { t } = useI18n();
|
|||||||
const toast = useMessage();
|
const toast = useMessage();
|
||||||
|
|
||||||
const isSystem = computed(() => props.message.role === "system");
|
const isSystem = computed(() => props.message.role === "system");
|
||||||
|
const isCommandMessage = computed(() => props.message.role === "command" || props.message.systemType === "command");
|
||||||
|
const isCommandError = computed(() => props.message.role === "command" && props.message.systemType === "error");
|
||||||
|
const isStatusCommand = computed(() => isCommandMessage.value && props.message.commandAction === "status");
|
||||||
|
const statusItems = computed(() => {
|
||||||
|
const data = props.message.commandData || {};
|
||||||
|
return [
|
||||||
|
{ key: "status", value: data.isWorking ? "running" : "idle" },
|
||||||
|
{ key: "source", value: data.source },
|
||||||
|
{ key: "profile", value: data.profile },
|
||||||
|
{ key: "model", value: data.model || "-" },
|
||||||
|
{ key: "queue", value: data.queueLength ?? 0 },
|
||||||
|
{ key: "run", value: data.runId || "-" },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
// Parse ContentBlock[] from JSON string
|
// Parse ContentBlock[] from JSON string
|
||||||
const contentBlocks = computed(() => {
|
const contentBlocks = computed(() => {
|
||||||
@@ -572,7 +586,15 @@ onBeforeUnmount(() => {
|
|||||||
class="msg-avatar"
|
class="msg-avatar"
|
||||||
/>
|
/>
|
||||||
<div class="msg-content" :class="message.role">
|
<div class="msg-content" :class="message.role">
|
||||||
<div class="message-bubble" :class="{ system: isSystem, 'speech-playing': isPlayingThisMessage && !isPausedThisMessage }">
|
<div
|
||||||
|
class="message-bubble"
|
||||||
|
:class="{
|
||||||
|
system: isSystem,
|
||||||
|
command: isCommandMessage,
|
||||||
|
'command-error': isCommandError,
|
||||||
|
'speech-playing': isPlayingThisMessage && !isPausedThisMessage,
|
||||||
|
}"
|
||||||
|
>
|
||||||
<div v-if="hasAttachments" class="msg-attachments">
|
<div v-if="hasAttachments" class="msg-attachments">
|
||||||
<div
|
<div
|
||||||
v-for="att in message.attachments"
|
v-for="att in message.attachments"
|
||||||
@@ -703,6 +725,29 @@ onBeforeUnmount(() => {
|
|||||||
:content="message.content"
|
:content="message.content"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Render system message content -->
|
||||||
|
<MarkdownRenderer
|
||||||
|
v-if="message.role === 'system' && message.content && !isCommandMessage"
|
||||||
|
:content="message.content"
|
||||||
|
/>
|
||||||
|
<div v-if="isStatusCommand" class="command-result command-status">
|
||||||
|
<span class="command-result-icon">/</span>
|
||||||
|
<div class="command-status-grid">
|
||||||
|
<span
|
||||||
|
v-for="item in statusItems"
|
||||||
|
:key="item.key"
|
||||||
|
class="command-status-item"
|
||||||
|
>
|
||||||
|
<span class="command-status-key">{{ item.key }}</span>
|
||||||
|
<span class="command-status-value">{{ item.value }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isCommandMessage && message.content" class="command-result">
|
||||||
|
<span class="command-result-icon">/</span>
|
||||||
|
<MarkdownRenderer :content="message.content" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<span v-if="message.isStreaming && !message.content" class="streaming-dots">
|
<span v-if="message.isStreaming && !message.content" class="streaming-dots">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
</span>
|
</span>
|
||||||
@@ -806,6 +851,10 @@ onBeforeUnmount(() => {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.command {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
&.highlight {
|
&.highlight {
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
box-shadow: 0 0 0 1px rgba(var(--accent-primary-rgb), 0.45);
|
box-shadow: 0 0 0 1px rgba(var(--accent-primary-rgb), 0.45);
|
||||||
@@ -855,6 +904,20 @@ onBeforeUnmount(() => {
|
|||||||
background-color: rgba(var(--warning-rgb), 0.06);
|
background-color: rgba(var(--warning-rgb), 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.command {
|
||||||
|
border-left: none;
|
||||||
|
border: 1px solid rgba(var(--accent-primary-rgb), 0.12);
|
||||||
|
background-color: rgba(var(--accent-primary-rgb), 0.04);
|
||||||
|
color: $text-secondary;
|
||||||
|
max-width: min(100%, 960px);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.command-error {
|
||||||
|
border-color: rgba(var(--warning-rgb), 0.28);
|
||||||
|
background-color: rgba(var(--warning-rgb), 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
&.speech-playing {
|
&.speech-playing {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 2px #ff6b6b,
|
0 0 0 2px #ff6b6b,
|
||||||
@@ -864,6 +927,74 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.command-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
:deep(.markdown-body) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.markdown-body p) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-status {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-status-grid {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-status-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid rgba(var(--accent-primary-rgb), 0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(var(--accent-primary-rgb), 0.035);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-status-key {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-status-value {
|
||||||
|
color: $text-primary;
|
||||||
|
font-family: $font-code;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-result-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||||
|
color: $accent-primary;
|
||||||
|
font-family: $font-code;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes rainbow-glow {
|
@keyframes rainbow-glow {
|
||||||
0% {
|
0% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
|
|||||||
@@ -121,6 +121,23 @@ export default {
|
|||||||
contextEditFailed: 'Aktualisierung fehlgeschlagen',
|
contextEditFailed: 'Aktualisierung fehlgeschlagen',
|
||||||
emptyState: 'Starten Sie eine Konversation mit Hermes Agent',
|
emptyState: 'Starten Sie eine Konversation mit Hermes Agent',
|
||||||
inputPlaceholder: 'Nachricht eingeben... (Enter zum Senden, Shift+Enter fur neue Zeile)',
|
inputPlaceholder: 'Nachricht eingeben... (Enter zum Senden, Shift+Enter fur neue Zeile)',
|
||||||
|
slashCommandArgs: {
|
||||||
|
message: '<Nachricht>',
|
||||||
|
title: '<Titel>',
|
||||||
|
text: '<Text>',
|
||||||
|
},
|
||||||
|
slashCommands: {
|
||||||
|
usage: 'Nutzung der aktuellen Sitzung berechnen',
|
||||||
|
status: 'Sitzungsstatus und Warteschlange anzeigen',
|
||||||
|
abort: 'Aktiven Bridge-Lauf stoppen',
|
||||||
|
queue: 'Nachricht hinter dem aktiven Lauf einreihen',
|
||||||
|
clear: 'Aktuelle Anzeige leeren',
|
||||||
|
clearHistory: 'Gespeicherten Nachrichtenverlauf dieser Sitzung löschen',
|
||||||
|
title: 'Diese Sitzung umbenennen',
|
||||||
|
compress: 'Kontextkomprimierung im Leerlauf ausführen',
|
||||||
|
steer: 'Steuertext an den aktiven Bridge-Lauf senden',
|
||||||
|
destroy: 'Bridge-Agent für diese Sitzung freigeben',
|
||||||
|
},
|
||||||
attachFiles: 'Dateien anhangen',
|
attachFiles: 'Dateien anhangen',
|
||||||
messageQueue: 'Nachrichtenwarteschlange',
|
messageQueue: 'Nachrichtenwarteschlange',
|
||||||
removeQueuedMessage: 'Nachricht aus Warteschlange entfernen',
|
removeQueuedMessage: 'Nachricht aus Warteschlange entfernen',
|
||||||
|
|||||||
@@ -134,6 +134,23 @@ export default {
|
|||||||
emptyState: 'Start a conversation with Hermes Agent',
|
emptyState: 'Start a conversation with Hermes Agent',
|
||||||
cliEmptyState: 'Start a CLI chat session',
|
cliEmptyState: 'Start a CLI chat session',
|
||||||
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
|
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
|
||||||
|
slashCommandArgs: {
|
||||||
|
message: '<message>',
|
||||||
|
title: '<title>',
|
||||||
|
text: '<text>',
|
||||||
|
},
|
||||||
|
slashCommands: {
|
||||||
|
usage: 'Calculate current session usage',
|
||||||
|
status: 'Show session status and queue',
|
||||||
|
abort: 'Stop the active bridge run',
|
||||||
|
queue: 'Queue a message behind the active run',
|
||||||
|
clear: 'Clear the current display',
|
||||||
|
clearHistory: 'Delete this session’s stored message history',
|
||||||
|
title: 'Rename this session',
|
||||||
|
compress: 'Run context compression while idle',
|
||||||
|
steer: 'Send steering text to the active bridge run',
|
||||||
|
destroy: 'Release the bridge agent for this session',
|
||||||
|
},
|
||||||
attachFiles: 'Attach files',
|
attachFiles: 'Attach files',
|
||||||
autoPlaySpeech: 'Auto-play voice',
|
autoPlaySpeech: 'Auto-play voice',
|
||||||
messageQueue: 'Message queue',
|
messageQueue: 'Message queue',
|
||||||
|
|||||||
@@ -121,6 +121,23 @@ export default {
|
|||||||
contextEditFailed: 'Error en la actualización',
|
contextEditFailed: 'Error en la actualización',
|
||||||
emptyState: 'Inicia una conversacion con Hermes Agent',
|
emptyState: 'Inicia una conversacion con Hermes Agent',
|
||||||
inputPlaceholder: 'Escribe un mensaje... (Enter para enviar, Shift+Enter para nueva linea)',
|
inputPlaceholder: 'Escribe un mensaje... (Enter para enviar, Shift+Enter para nueva linea)',
|
||||||
|
slashCommandArgs: {
|
||||||
|
message: '<mensaje>',
|
||||||
|
title: '<titulo>',
|
||||||
|
text: '<texto>',
|
||||||
|
},
|
||||||
|
slashCommands: {
|
||||||
|
usage: 'Calcular el uso de la sesión actual',
|
||||||
|
status: 'Mostrar estado de sesión y cola',
|
||||||
|
abort: 'Detener la ejecución activa de Bridge',
|
||||||
|
queue: 'Poner un mensaje en cola tras la ejecución activa',
|
||||||
|
clear: 'Limpiar la vista actual',
|
||||||
|
clearHistory: 'Eliminar el historial de mensajes guardado de esta sesión',
|
||||||
|
title: 'Renombrar esta sesión',
|
||||||
|
compress: 'Ejecutar compresión de contexto cuando esté inactiva',
|
||||||
|
steer: 'Enviar texto de guía a la ejecución activa de Bridge',
|
||||||
|
destroy: 'Liberar el agente Bridge de esta sesión',
|
||||||
|
},
|
||||||
attachFiles: 'Adjuntar archivos',
|
attachFiles: 'Adjuntar archivos',
|
||||||
messageQueue: 'Cola de mensajes',
|
messageQueue: 'Cola de mensajes',
|
||||||
removeQueuedMessage: 'Quitar mensaje de la cola',
|
removeQueuedMessage: 'Quitar mensaje de la cola',
|
||||||
|
|||||||
@@ -121,6 +121,23 @@ export default {
|
|||||||
contextEditFailed: 'Échec de la mise à jour',
|
contextEditFailed: 'Échec de la mise à jour',
|
||||||
emptyState: 'Demarrer une conversation avec Hermes Agent',
|
emptyState: 'Demarrer une conversation avec Hermes Agent',
|
||||||
inputPlaceholder: 'Tapez un message... (Entree pour envoyer, Shift+Entree pour un saut de ligne)',
|
inputPlaceholder: 'Tapez un message... (Entree pour envoyer, Shift+Entree pour un saut de ligne)',
|
||||||
|
slashCommandArgs: {
|
||||||
|
message: '<message>',
|
||||||
|
title: '<titre>',
|
||||||
|
text: '<texte>',
|
||||||
|
},
|
||||||
|
slashCommands: {
|
||||||
|
usage: 'Calculer l’utilisation de la session actuelle',
|
||||||
|
status: 'Afficher l’état de la session et la file',
|
||||||
|
abort: 'Arrêter l’exécution Bridge active',
|
||||||
|
queue: 'Mettre un message en file après l’exécution active',
|
||||||
|
clear: 'Effacer l’affichage actuel',
|
||||||
|
clearHistory: 'Supprimer l’historique des messages enregistrés de cette session',
|
||||||
|
title: 'Renommer cette session',
|
||||||
|
compress: 'Lancer la compression du contexte au repos',
|
||||||
|
steer: 'Envoyer un guidage à l’exécution Bridge active',
|
||||||
|
destroy: 'Libérer l’agent Bridge de cette session',
|
||||||
|
},
|
||||||
attachFiles: 'Joindre des fichiers',
|
attachFiles: 'Joindre des fichiers',
|
||||||
messageQueue: 'File de messages',
|
messageQueue: 'File de messages',
|
||||||
removeQueuedMessage: 'Retirer le message de la file',
|
removeQueuedMessage: 'Retirer le message de la file',
|
||||||
|
|||||||
@@ -121,6 +121,23 @@ export default {
|
|||||||
contextEditFailed: '更新に失敗しました',
|
contextEditFailed: '更新に失敗しました',
|
||||||
emptyState: 'Hermes Agent と会話を開始しましょう',
|
emptyState: 'Hermes Agent と会話を開始しましょう',
|
||||||
inputPlaceholder: 'メッセージを入力... (Enter で送信、Shift+Enter で改行)',
|
inputPlaceholder: 'メッセージを入力... (Enter で送信、Shift+Enter で改行)',
|
||||||
|
slashCommandArgs: {
|
||||||
|
message: '<メッセージ>',
|
||||||
|
title: '<タイトル>',
|
||||||
|
text: '<テキスト>',
|
||||||
|
},
|
||||||
|
slashCommands: {
|
||||||
|
usage: '現在のセッション使用量を計算',
|
||||||
|
status: 'セッション状態とキューを表示',
|
||||||
|
abort: '実行中の Bridge を停止',
|
||||||
|
queue: '実行中の処理の後ろにメッセージをキュー追加',
|
||||||
|
clear: '現在の表示をクリア',
|
||||||
|
clearHistory: 'このセッションの保存済みメッセージ履歴を削除',
|
||||||
|
title: 'このセッション名を変更',
|
||||||
|
compress: 'アイドル時にコンテキスト圧縮を実行',
|
||||||
|
steer: '実行中の Bridge に誘導テキストを送信',
|
||||||
|
destroy: 'このセッションの Bridge Agent を解放',
|
||||||
|
},
|
||||||
attachFiles: 'ファイルを添付',
|
attachFiles: 'ファイルを添付',
|
||||||
messageQueue: 'メッセージキュー',
|
messageQueue: 'メッセージキュー',
|
||||||
removeQueuedMessage: 'キューのメッセージを削除',
|
removeQueuedMessage: 'キューのメッセージを削除',
|
||||||
|
|||||||
@@ -121,6 +121,23 @@ export default {
|
|||||||
contextEditFailed: '업데이트 실패',
|
contextEditFailed: '업데이트 실패',
|
||||||
emptyState: 'Hermes Agent와 대화를 시작하세요',
|
emptyState: 'Hermes Agent와 대화를 시작하세요',
|
||||||
inputPlaceholder: '메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)',
|
inputPlaceholder: '메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)',
|
||||||
|
slashCommandArgs: {
|
||||||
|
message: '<메시지>',
|
||||||
|
title: '<제목>',
|
||||||
|
text: '<텍스트>',
|
||||||
|
},
|
||||||
|
slashCommands: {
|
||||||
|
usage: '현재 세션 사용량 계산',
|
||||||
|
status: '세션 상태와 대기열 표시',
|
||||||
|
abort: '활성 Bridge 실행 중지',
|
||||||
|
queue: '활성 실행 뒤에 메시지 대기열 추가',
|
||||||
|
clear: '현재 표시 내용 지우기',
|
||||||
|
clearHistory: '이 세션의 저장된 메시지 기록 삭제',
|
||||||
|
title: '이 세션 이름 변경',
|
||||||
|
compress: '유휴 상태에서 컨텍스트 압축 실행',
|
||||||
|
steer: '활성 Bridge 실행에 지시 텍스트 보내기',
|
||||||
|
destroy: '이 세션의 Bridge Agent 해제',
|
||||||
|
},
|
||||||
attachFiles: '파일 첨부',
|
attachFiles: '파일 첨부',
|
||||||
messageQueue: '메시지 대기열',
|
messageQueue: '메시지 대기열',
|
||||||
removeQueuedMessage: '대기열 메시지 제거',
|
removeQueuedMessage: '대기열 메시지 제거',
|
||||||
|
|||||||
@@ -121,6 +121,23 @@ export default {
|
|||||||
contextEditFailed: 'Falha na atualização',
|
contextEditFailed: 'Falha na atualização',
|
||||||
emptyState: 'Inicie uma conversa com o Hermes Agent',
|
emptyState: 'Inicie uma conversa com o Hermes Agent',
|
||||||
inputPlaceholder: 'Digite uma mensagem... (Enter para enviar, Shift+Enter para nova linha)',
|
inputPlaceholder: 'Digite uma mensagem... (Enter para enviar, Shift+Enter para nova linha)',
|
||||||
|
slashCommandArgs: {
|
||||||
|
message: '<mensagem>',
|
||||||
|
title: '<titulo>',
|
||||||
|
text: '<texto>',
|
||||||
|
},
|
||||||
|
slashCommands: {
|
||||||
|
usage: 'Calcular o uso da sessão atual',
|
||||||
|
status: 'Mostrar status da sessão e fila',
|
||||||
|
abort: 'Parar a execução ativa do Bridge',
|
||||||
|
queue: 'Enfileirar uma mensagem após a execução ativa',
|
||||||
|
clear: 'Limpar a visualização atual',
|
||||||
|
clearHistory: 'Excluir o histórico de mensagens salvo desta sessão',
|
||||||
|
title: 'Renomear esta sessão',
|
||||||
|
compress: 'Executar compressão de contexto quando ocioso',
|
||||||
|
steer: 'Enviar texto de orientação para a execução ativa do Bridge',
|
||||||
|
destroy: 'Liberar o Bridge Agent desta sessão',
|
||||||
|
},
|
||||||
attachFiles: 'Anexar arquivos',
|
attachFiles: 'Anexar arquivos',
|
||||||
messageQueue: 'Fila de mensagens',
|
messageQueue: 'Fila de mensagens',
|
||||||
removeQueuedMessage: 'Remover mensagem da fila',
|
removeQueuedMessage: 'Remover mensagem da fila',
|
||||||
|
|||||||
@@ -133,6 +133,23 @@ export default {
|
|||||||
contextEditFailed: '更新失敗',
|
contextEditFailed: '更新失敗',
|
||||||
emptyState: '開始與 Hermes Agent 對話',
|
emptyState: '開始與 Hermes Agent 對話',
|
||||||
inputPlaceholder: '輸入訊息... (Enter 發送,Shift+Enter 換行)',
|
inputPlaceholder: '輸入訊息... (Enter 發送,Shift+Enter 換行)',
|
||||||
|
slashCommandArgs: {
|
||||||
|
message: '<訊息>',
|
||||||
|
title: '<標題>',
|
||||||
|
text: '<文字>',
|
||||||
|
},
|
||||||
|
slashCommands: {
|
||||||
|
usage: '計算目前會話用量',
|
||||||
|
status: '查看會話狀態和佇列',
|
||||||
|
abort: '停止目前 Bridge 執行',
|
||||||
|
queue: '將訊息加入目前執行後的佇列',
|
||||||
|
clear: '清空目前顯示內容',
|
||||||
|
clearHistory: '刪除目前會話已儲存的訊息歷史',
|
||||||
|
title: '重新命名目前會話',
|
||||||
|
compress: '空閒時觸發上下文壓縮',
|
||||||
|
steer: '向目前 Bridge 執行傳送引導文字',
|
||||||
|
destroy: '釋放目前會話的 Bridge Agent',
|
||||||
|
},
|
||||||
attachFiles: '新增附件',
|
attachFiles: '新增附件',
|
||||||
autoPlaySpeech: '自動播放語音',
|
autoPlaySpeech: '自動播放語音',
|
||||||
messageQueue: '訊息佇列',
|
messageQueue: '訊息佇列',
|
||||||
|
|||||||
@@ -134,6 +134,23 @@ export default {
|
|||||||
emptyState: '开始与 Hermes Agent 对话',
|
emptyState: '开始与 Hermes Agent 对话',
|
||||||
cliEmptyState: '开始 CLI 对话',
|
cliEmptyState: '开始 CLI 对话',
|
||||||
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
|
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
|
||||||
|
slashCommandArgs: {
|
||||||
|
message: '<消息>',
|
||||||
|
title: '<标题>',
|
||||||
|
text: '<文本>',
|
||||||
|
},
|
||||||
|
slashCommands: {
|
||||||
|
usage: '计算当前会话用量',
|
||||||
|
status: '查看会话状态和队列',
|
||||||
|
abort: '停止当前 Bridge 运行',
|
||||||
|
queue: '把消息加入当前运行后的队列',
|
||||||
|
clear: '清空当前显示内容',
|
||||||
|
clearHistory: '删除当前会话已入库的消息历史',
|
||||||
|
title: '重命名当前会话',
|
||||||
|
compress: '空闲时触发上下文压缩',
|
||||||
|
steer: '向当前 Bridge 运行发送引导文本',
|
||||||
|
destroy: '释放当前会话的 Bridge Agent',
|
||||||
|
},
|
||||||
attachFiles: '添加附件',
|
attachFiles: '添加附件',
|
||||||
autoPlaySpeech: '自动播放语音',
|
autoPlaySpeech: '自动播放语音',
|
||||||
messageQueue: '消息队列',
|
messageQueue: '消息队列',
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface Attachment {
|
|||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
role: 'user' | 'assistant' | 'system' | 'tool' | 'command'
|
||||||
content: string
|
content: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
toolName?: string
|
toolName?: string
|
||||||
@@ -41,6 +41,9 @@ export interface Message {
|
|||||||
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
|
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
|
||||||
reasoning?: string
|
reasoning?: string
|
||||||
queued?: boolean
|
queued?: boolean
|
||||||
|
systemType?: 'command' | 'error'
|
||||||
|
commandAction?: string
|
||||||
|
commandData?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingApproval {
|
export interface PendingApproval {
|
||||||
@@ -212,13 +215,14 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal user/assistant messages
|
// Normal user/assistant/command messages
|
||||||
result.push({
|
result.push({
|
||||||
id: String(msg.id),
|
id: String(msg.id),
|
||||||
role: msg.role,
|
role: msg.role,
|
||||||
content: msg.content || '',
|
content: msg.content || '',
|
||||||
timestamp: Math.round(msg.timestamp * 1000),
|
timestamp: Math.round(msg.timestamp * 1000),
|
||||||
reasoning: msg.reasoning ? msg.reasoning : undefined,
|
reasoning: msg.reasoning ? msg.reasoning : undefined,
|
||||||
|
systemType: msg.role === 'command' ? 'command' : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -663,6 +667,70 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSessionCommandEvent(evt: RunEvent) {
|
||||||
|
const sid = evt.session_id
|
||||||
|
if (!sid) return
|
||||||
|
const target = sessions.value.find(s => s.id === sid)
|
||||||
|
const action = (evt as any).action as string | undefined
|
||||||
|
|
||||||
|
if (action === 'clear') {
|
||||||
|
if (target) target.messages = []
|
||||||
|
queuedUserMessages.value.delete(sid)
|
||||||
|
queueLengths.value.delete(sid)
|
||||||
|
if ((evt as any).clearHistory) {
|
||||||
|
const message = String((evt as any).message || '')
|
||||||
|
if (message) {
|
||||||
|
addMessage(sid, {
|
||||||
|
id: uid(),
|
||||||
|
role: 'command',
|
||||||
|
content: message,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
systemType: (evt as any).ok === false ? 'error' : 'command',
|
||||||
|
commandAction: action,
|
||||||
|
commandData: { ...(evt as any) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'title' && target && typeof (evt as any).title === 'string') {
|
||||||
|
target.title = (evt as any).title
|
||||||
|
target.updatedAt = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'usage' && target) {
|
||||||
|
target.inputTokens = (evt as any).inputTokens
|
||||||
|
target.outputTokens = (evt as any).outputTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'destroy') {
|
||||||
|
streamStates.value.delete(sid)
|
||||||
|
serverWorking.value.delete(sid)
|
||||||
|
queueLengths.value.delete(sid)
|
||||||
|
queuedUserMessages.value.delete(sid)
|
||||||
|
setAbortState(null)
|
||||||
|
const msgs = getSessionMsgs(sid)
|
||||||
|
msgs.forEach(m => {
|
||||||
|
if (m.isStreaming) updateMessage(sid, m.id, { isStreaming: false })
|
||||||
|
if (m.role === 'tool' && m.toolStatus === 'running') m.toolStatus = 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = String((evt as any).message || '')
|
||||||
|
if (message) {
|
||||||
|
addMessage(sid, {
|
||||||
|
id: uid(),
|
||||||
|
role: 'command',
|
||||||
|
content: message,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
systemType: (evt as any).ok === false ? 'error' : 'command',
|
||||||
|
commandAction: action,
|
||||||
|
commandData: { ...(evt as any) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function enqueueUserMessage(sessionId: string, message: Message) {
|
function enqueueUserMessage(sessionId: string, message: Message) {
|
||||||
const queue = queuedUserMessages.value.get(sessionId) || []
|
const queue = queuedUserMessages.value.get(sessionId) || []
|
||||||
queue.push({ ...message, queued: true })
|
queue.push({ ...message, queued: true })
|
||||||
@@ -776,15 +844,18 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
// Capture session ID at send time — all callbacks use this, not activeSessionId
|
// Capture session ID at send time — all callbacks use this, not activeSessionId
|
||||||
const sid = activeSessionId.value!
|
const sid = activeSessionId.value!
|
||||||
const shouldQueue = isSessionLive(sid)
|
const isBridgeSlashCommand = activeSession.value?.source === 'cli' && content.trim().startsWith('/')
|
||||||
|
const wasLiveBeforeSend = isSessionLive(sid)
|
||||||
|
const shouldQueue = wasLiveBeforeSend && !isBridgeSlashCommand
|
||||||
|
|
||||||
const userMsg: Message = {
|
const userMsg: Message = {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
role: 'user',
|
role: isBridgeSlashCommand ? 'command' : 'user',
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||||
queued: shouldQueue,
|
queued: shouldQueue,
|
||||||
|
systemType: isBridgeSlashCommand ? 'command' : undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldQueue) {
|
if (!shouldQueue) {
|
||||||
@@ -897,6 +968,11 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'session.command': {
|
||||||
|
handleSessionCommandEvent(evt)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'compression.started': {
|
case 'compression.started': {
|
||||||
setCompressionState({
|
setCompressionState({
|
||||||
compressing: true,
|
compressing: true,
|
||||||
@@ -1272,7 +1348,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
streamStates.value.set(sid, ctrl)
|
if (!isBridgeSlashCommand || !wasLiveBeforeSend) {
|
||||||
|
streamStates.value.set(sid, ctrl)
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
addMessage(sid, {
|
addMessage(sid, {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
@@ -1333,6 +1411,11 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'session.command': {
|
||||||
|
handleSessionCommandEvent(evt)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'run.started':
|
case 'run.started':
|
||||||
setAbortState(null)
|
setAbortState(null)
|
||||||
runProducedAssistantText = false
|
runProducedAssistantText = false
|
||||||
@@ -1685,6 +1768,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
onAbortStarted: (evt) => handleEvent(evt),
|
onAbortStarted: (evt) => handleEvent(evt),
|
||||||
onAbortCompleted: (evt) => handleEvent(evt),
|
onAbortCompleted: (evt) => handleEvent(evt),
|
||||||
onUsageUpdated: (evt) => handleEvent(evt),
|
onUsageUpdated: (evt) => handleEvent(evt),
|
||||||
|
onSessionCommand: (evt) => handleEvent(evt),
|
||||||
onRunQueued: (evt) => handleEvent(evt),
|
onRunQueued: (evt) => handleEvent(evt),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,10 @@ export async function handleUpdate(ctx: any) {
|
|||||||
})
|
})
|
||||||
restart.on('exit', (code, signal) => {
|
restart.on('exit', (code, signal) => {
|
||||||
updateInProgress = false
|
updateInProgress = false
|
||||||
console.error(`[update] restart process exited before replacing server: code=${code} signal=${signal}`)
|
const failed = (typeof code === 'number' && code !== 0) || Boolean(signal)
|
||||||
|
if (failed) {
|
||||||
|
console.error(`[update] restart process exited before replacing server: code=${code} signal=${signal}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
restart.unref()
|
restart.unref()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|||||||
@@ -201,6 +201,14 @@ export function deleteSession(id: string): boolean {
|
|||||||
return result.changes > 0
|
return result.changes > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearSessionMessages(id: string): number {
|
||||||
|
if (!isSqliteAvailable()) return 0
|
||||||
|
const db = getDb()!
|
||||||
|
const result = db.prepare(`DELETE FROM ${MESSAGES_TABLE} WHERE session_id = ?`).run(id)
|
||||||
|
updateSessionStats(id)
|
||||||
|
return Number(result.changes)
|
||||||
|
}
|
||||||
|
|
||||||
export function renameSession(id: string, title: string): boolean {
|
export function renameSession(id: string, title: string): boolean {
|
||||||
if (!isSqliteAvailable()) return false
|
if (!isSqliteAvailable()) return false
|
||||||
const db = getDb()!
|
const db = getDb()!
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { handleApiRun, resolveRunSource, loadSessionStateFromDb } from './handle
|
|||||||
import { handleBridgeRun } from './handle-bridge-run'
|
import { handleBridgeRun } from './handle-bridge-run'
|
||||||
import { handleAbort } from './abort'
|
import { handleAbort } from './abort'
|
||||||
import { getOrCreateSession } from './compression'
|
import { getOrCreateSession } from './compression'
|
||||||
|
import { handleSessionCommand, isSessionCommand, parseSessionCommand } from './session-command'
|
||||||
import type { ContentBlock, QueuedRun, SessionState } from './types'
|
import type { ContentBlock, QueuedRun, SessionState } from './types'
|
||||||
|
|
||||||
export type { ContentBlock } from './types'
|
export type { ContentBlock } from './types'
|
||||||
@@ -70,6 +71,32 @@ export class ChatRunSocket {
|
|||||||
}) => {
|
}) => {
|
||||||
if (data.session_id) {
|
if (data.session_id) {
|
||||||
const state = getOrCreateSession(this.sessionMap, data.session_id)
|
const state = getOrCreateSession(this.sessionMap, data.session_id)
|
||||||
|
const source = resolveRunSource(data.source, data.session_id)
|
||||||
|
const command = parseSessionCommand(data.input)
|
||||||
|
if (command && source === 'cli') {
|
||||||
|
try {
|
||||||
|
await handleSessionCommand(data.session_id, command, {
|
||||||
|
nsp: this.nsp,
|
||||||
|
socket,
|
||||||
|
sessionMap: this.sessionMap,
|
||||||
|
bridge: this.bridge,
|
||||||
|
gatewayManager: this.gatewayManager,
|
||||||
|
profile: currentProfile(),
|
||||||
|
model: data.model,
|
||||||
|
instructions: data.instructions,
|
||||||
|
runQueuedItem: this.runQueuedItem.bind(this),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
this.emitToSession(socket, data.session_id, 'session.command', {
|
||||||
|
event: 'session.command',
|
||||||
|
command: command.rawName,
|
||||||
|
ok: false,
|
||||||
|
action: 'error',
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (state.isWorking) {
|
if (state.isWorking) {
|
||||||
state.queue.push({
|
state.queue.push({
|
||||||
queue_id: data.queue_id || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
queue_id: data.queue_id || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
@@ -77,7 +104,7 @@ export class ChatRunSocket {
|
|||||||
model: data.model,
|
model: data.model,
|
||||||
instructions: data.instructions,
|
instructions: data.instructions,
|
||||||
profile: currentProfile(),
|
profile: currentProfile(),
|
||||||
source: resolveRunSource(data.source, data.session_id),
|
source,
|
||||||
})
|
})
|
||||||
this.nsp.to(`session:${data.session_id}`).emit('run.queued', {
|
this.nsp.to(`session:${data.session_id}`).emit('run.queued', {
|
||||||
event: 'run.queued',
|
event: 'run.queued',
|
||||||
@@ -89,7 +116,7 @@ export class ChatRunSocket {
|
|||||||
}
|
}
|
||||||
state.isWorking = true
|
state.isWorking = true
|
||||||
state.profile = currentProfile()
|
state.profile = currentProfile()
|
||||||
state.source = resolveRunSource(data.source, data.session_id)
|
state.source = source
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.handleRun(socket, data, currentProfile())
|
await this.handleRun(socket, data, currentProfile())
|
||||||
@@ -169,6 +196,7 @@ export class ChatRunSocket {
|
|||||||
skipUserMessage = false,
|
skipUserMessage = false,
|
||||||
) {
|
) {
|
||||||
const source = resolveRunSource(data.source, data.session_id)
|
const source = resolveRunSource(data.source, data.session_id)
|
||||||
|
if (data.session_id && source === 'cli' && isSessionCommand(data.input)) return
|
||||||
|
|
||||||
if (source === 'cli') {
|
if (source === 'cli') {
|
||||||
let fullInstructions = data.instructions
|
let fullInstructions = data.instructions
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function handleMessage(messages: SessionMessage[], sid: string): any[] {
|
|||||||
let _messages = []
|
let _messages = []
|
||||||
try {
|
try {
|
||||||
_messages = messages
|
_messages = messages
|
||||||
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'tool') && m.content !== undefined)
|
.filter(m => (m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'command') && m.content !== undefined)
|
||||||
.map((m, idx, arr) => {
|
.map((m, idx, arr) => {
|
||||||
const msg: any = {
|
const msg: any = {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import type { Server, Socket } from 'socket.io'
|
||||||
|
import { addMessage, clearSessionMessages, createSession, getSession, renameSession, updateSessionStats } from '../../../db/hermes/session-store'
|
||||||
|
import { logger } from '../../logger'
|
||||||
|
import type { AgentBridgeClient } from '../agent-bridge'
|
||||||
|
import { flushBridgePendingToDb } from './bridge-message'
|
||||||
|
import { buildDbHistory, forceCompressBridgeHistory, getOrCreateSession, replaceState } from './compression'
|
||||||
|
import { handleAbort } from './abort'
|
||||||
|
import { calcAndUpdateUsage } from './usage'
|
||||||
|
import { countTokens } from '../../../lib/context-compressor'
|
||||||
|
import type { ContentBlock, QueuedRun, SessionState } from './types'
|
||||||
|
|
||||||
|
type CommandName =
|
||||||
|
| 'usage'
|
||||||
|
| 'status'
|
||||||
|
| 'abort'
|
||||||
|
| 'queue'
|
||||||
|
| 'clear'
|
||||||
|
| 'title'
|
||||||
|
| 'compress'
|
||||||
|
| 'steer'
|
||||||
|
| 'destroy'
|
||||||
|
|
||||||
|
interface ParsedSessionCommand {
|
||||||
|
name: CommandName
|
||||||
|
rawName: string
|
||||||
|
args: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionCommandContext {
|
||||||
|
nsp: ReturnType<Server['of']>
|
||||||
|
socket: Socket
|
||||||
|
sessionMap: Map<string, SessionState>
|
||||||
|
bridge: AgentBridgeClient
|
||||||
|
gatewayManager: any
|
||||||
|
profile: string
|
||||||
|
model?: string
|
||||||
|
instructions?: string
|
||||||
|
runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_ALIASES: Record<string, CommandName> = {
|
||||||
|
usage: 'usage',
|
||||||
|
status: 'status',
|
||||||
|
abort: 'abort',
|
||||||
|
queue: 'queue',
|
||||||
|
clear: 'clear',
|
||||||
|
title: 'title',
|
||||||
|
compress: 'compress',
|
||||||
|
steer: 'steer',
|
||||||
|
destroy: 'destroy',
|
||||||
|
destory: 'destroy',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSessionCommand(input: string | ContentBlock[]): ParsedSessionCommand | null {
|
||||||
|
if (typeof input !== 'string') return null
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed.startsWith('/')) return null
|
||||||
|
const match = trimmed.match(/^\/([a-zA-Z][\w-]*)(?:\s+([\s\S]*))?$/)
|
||||||
|
if (!match) return null
|
||||||
|
const rawName = match[1].toLowerCase()
|
||||||
|
const name = COMMAND_ALIASES[rawName]
|
||||||
|
if (!name) return { name: 'status', rawName, args: match[2]?.trim() || '' }
|
||||||
|
return { name, rawName, args: match[2]?.trim() || '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSessionCommand(input: string | ContentBlock[]): boolean {
|
||||||
|
return parseSessionCommand(input) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleSessionCommand(
|
||||||
|
sessionId: string,
|
||||||
|
command: ParsedSessionCommand,
|
||||||
|
ctx: SessionCommandContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const state = getOrCreateSession(ctx.sessionMap, sessionId)
|
||||||
|
ctx.socket.join(`session:${sessionId}`)
|
||||||
|
ensureCommandSession(sessionId, ctx)
|
||||||
|
persistCommandMessage(sessionId, state, `/${command.rawName}${command.args ? ` ${command.args}` : ''}`)
|
||||||
|
|
||||||
|
const emitCommand = (payload: Record<string, unknown>) => {
|
||||||
|
const message = typeof payload.message === 'string' ? payload.message : ''
|
||||||
|
if (message) persistCommandMessage(sessionId, state, message)
|
||||||
|
emitToSession(ctx.nsp, ctx.socket, sessionId, 'session.command', {
|
||||||
|
event: 'session.command',
|
||||||
|
session_id: sessionId,
|
||||||
|
command: command.rawName,
|
||||||
|
ok: true,
|
||||||
|
...payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!COMMAND_ALIASES[command.rawName]) {
|
||||||
|
emitCommand({
|
||||||
|
ok: false,
|
||||||
|
action: 'error',
|
||||||
|
terminal: !state.isWorking,
|
||||||
|
message: `Unknown bridge command: /${command.rawName}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (command.name) {
|
||||||
|
case 'usage': {
|
||||||
|
const usage = await calcAndUpdateUsage(sessionId, state, (event, payload) => {
|
||||||
|
emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload)
|
||||||
|
})
|
||||||
|
emitCommand({
|
||||||
|
action: 'usage',
|
||||||
|
terminal: !state.isWorking,
|
||||||
|
message: `Usage: input ${usage.inputTokens}, output ${usage.outputTokens}, total ${usage.inputTokens + usage.outputTokens} tokens.`,
|
||||||
|
inputTokens: usage.inputTokens,
|
||||||
|
outputTokens: usage.outputTokens,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'status': {
|
||||||
|
const row = getSession(sessionId)
|
||||||
|
emitCommand({
|
||||||
|
action: 'status',
|
||||||
|
terminal: !state.isWorking,
|
||||||
|
message: [
|
||||||
|
`Status: ${state.isWorking ? 'running' : 'idle'}`,
|
||||||
|
`source: ${state.source || row?.source || 'cli'}`,
|
||||||
|
`profile: ${state.profile || ctx.profile || row?.profile || 'default'}`,
|
||||||
|
`model: ${ctx.model || row?.model || '-'}`,
|
||||||
|
`queue: ${state.queue.length}`,
|
||||||
|
`run: ${state.runId || state.activeRunMarker || '-'}`,
|
||||||
|
].join(', '),
|
||||||
|
isWorking: state.isWorking,
|
||||||
|
isAborting: Boolean(state.isAborting),
|
||||||
|
queueLength: state.queue.length,
|
||||||
|
source: state.source || row?.source || 'cli',
|
||||||
|
profile: state.profile || ctx.profile || row?.profile || 'default',
|
||||||
|
model: ctx.model || row?.model || null,
|
||||||
|
runId: state.runId || state.activeRunMarker || null,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'abort':
|
||||||
|
await handleAbort(ctx.nsp, ctx.socket, sessionId, ctx.sessionMap, ctx.bridge, ctx.runQueuedItem)
|
||||||
|
emitCommand({ action: 'abort', message: 'Abort requested.' })
|
||||||
|
return
|
||||||
|
|
||||||
|
case 'queue': {
|
||||||
|
if (!command.args) {
|
||||||
|
emitCommand({ ok: false, action: 'queue', terminal: !state.isWorking, message: 'Usage: /queue <message>' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!state.isWorking) {
|
||||||
|
emitCommand({ ok: false, action: 'queue', message: 'Session is idle. Send the message normally instead.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.queue.push({
|
||||||
|
queue_id: `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
input: command.args,
|
||||||
|
model: ctx.model,
|
||||||
|
instructions: ctx.instructions,
|
||||||
|
profile: ctx.profile,
|
||||||
|
source: 'cli',
|
||||||
|
})
|
||||||
|
emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', {
|
||||||
|
event: 'run.queued',
|
||||||
|
session_id: sessionId,
|
||||||
|
queue_length: state.queue.length,
|
||||||
|
})
|
||||||
|
emitCommand({
|
||||||
|
action: 'queue',
|
||||||
|
terminal: false,
|
||||||
|
message: `Queued message. Queue length: ${state.queue.length}.`,
|
||||||
|
queueLength: state.queue.length,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'clear': {
|
||||||
|
if (command.args === '--history') {
|
||||||
|
if (state.isWorking) {
|
||||||
|
emitCommand({
|
||||||
|
ok: false,
|
||||||
|
action: 'clear',
|
||||||
|
terminal: false,
|
||||||
|
message: 'Cannot clear history while the bridge run is active. Abort or destroy it first.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const deleted = clearSessionMessages(sessionId)
|
||||||
|
state.messages = []
|
||||||
|
clearTransientRunState(state)
|
||||||
|
await calcAndUpdateUsage(sessionId, state, (event, payload) => {
|
||||||
|
emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload)
|
||||||
|
})
|
||||||
|
emitCommand({
|
||||||
|
action: 'clear',
|
||||||
|
clearHistory: true,
|
||||||
|
message: `Cleared ${deleted} history messages from the database.`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitCommand({
|
||||||
|
action: 'clear',
|
||||||
|
message: 'Cleared the current display. History in the database was not deleted.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'title': {
|
||||||
|
if (!command.args) {
|
||||||
|
emitCommand({ ok: false, action: 'title', terminal: !state.isWorking, message: 'Usage: /title <new title>' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const title = command.args.slice(0, 120)
|
||||||
|
if (!getSession(sessionId)) {
|
||||||
|
createSession({ id: sessionId, profile: ctx.profile, source: 'cli', model: ctx.model, title })
|
||||||
|
}
|
||||||
|
const updated = renameSession(sessionId, title)
|
||||||
|
emitCommand({
|
||||||
|
ok: updated,
|
||||||
|
action: 'title',
|
||||||
|
title,
|
||||||
|
message: updated ? `Title updated: ${title}` : 'Session was not found in the database.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'compress': {
|
||||||
|
if (state.isWorking) {
|
||||||
|
emitCommand({ ok: false, action: 'compress', terminal: false, message: 'Compression can only run while the session is idle.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearTransientRunState(state)
|
||||||
|
const emit = (event: string, payload: any) => emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload)
|
||||||
|
try {
|
||||||
|
const history = await buildDbHistory(sessionId, { excludeLastUser: true })
|
||||||
|
const tokenEstimate = history.length > 0 ? countTokens(JSON.stringify(history)) : 0
|
||||||
|
emit('compression.started', {
|
||||||
|
event: 'compression.started',
|
||||||
|
message_count: history.length,
|
||||||
|
token_count: tokenEstimate,
|
||||||
|
source: 'command',
|
||||||
|
})
|
||||||
|
const result = await forceCompressBridgeHistory(
|
||||||
|
sessionId,
|
||||||
|
ctx.profile,
|
||||||
|
[],
|
||||||
|
(profile: string) => ctx.gatewayManager.getUpstream(profile),
|
||||||
|
(profile: string) => ctx.gatewayManager.getApiKey(profile),
|
||||||
|
)
|
||||||
|
state.bridgeCompressionResults = state.bridgeCompressionResults || {}
|
||||||
|
await calcAndUpdateUsage(sessionId, state, emit)
|
||||||
|
emit('compression.completed', {
|
||||||
|
event: 'compression.completed',
|
||||||
|
compressed: result.compressed,
|
||||||
|
llmCompressed: result.llmCompressed,
|
||||||
|
totalMessages: result.beforeMessages,
|
||||||
|
resultMessages: result.resultMessages,
|
||||||
|
beforeTokens: result.beforeTokens,
|
||||||
|
afterTokens: result.afterTokens,
|
||||||
|
summaryTokens: result.summaryTokens,
|
||||||
|
verbatimCount: result.verbatimCount,
|
||||||
|
compressedStartIndex: result.compressedStartIndex,
|
||||||
|
source: 'command',
|
||||||
|
})
|
||||||
|
emitCommand({
|
||||||
|
action: 'compress',
|
||||||
|
message: `Compression completed: ${result.beforeMessages} -> ${result.resultMessages} messages, ${result.beforeTokens} -> ${result.afterTokens} tokens.`,
|
||||||
|
beforeMessages: result.beforeMessages,
|
||||||
|
resultMessages: result.resultMessages,
|
||||||
|
beforeTokens: result.beforeTokens,
|
||||||
|
afterTokens: result.afterTokens,
|
||||||
|
compressed: result.compressed,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(err, '[chat-run-socket] /compress failed for session %s', sessionId)
|
||||||
|
emit('compression.completed', {
|
||||||
|
event: 'compression.completed',
|
||||||
|
compressed: false,
|
||||||
|
totalMessages: 0,
|
||||||
|
resultMessages: 0,
|
||||||
|
beforeTokens: 0,
|
||||||
|
afterTokens: 0,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
source: 'command',
|
||||||
|
})
|
||||||
|
emitCommand({
|
||||||
|
ok: false,
|
||||||
|
action: 'compress',
|
||||||
|
message: `Compression failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'steer': {
|
||||||
|
if (!command.args) {
|
||||||
|
emitCommand({ ok: false, action: 'steer', terminal: !state.isWorking, message: 'Usage: /steer <instruction>' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!state.isWorking) {
|
||||||
|
emitCommand({ ok: false, action: 'steer', message: 'No active bridge run to steer.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await ctx.bridge.steer(sessionId, command.args)
|
||||||
|
emitCommand({ action: 'steer', terminal: false, message: 'Steer instruction sent.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'destroy': {
|
||||||
|
const wasWorking = state.isWorking
|
||||||
|
let bridgeReachable = true
|
||||||
|
let bridgeError: string | null = null
|
||||||
|
try {
|
||||||
|
if (wasWorking) {
|
||||||
|
flushBridgePendingToDb(state, sessionId)
|
||||||
|
await ctx.bridge.interrupt(sessionId, 'Destroyed by user').catch((err) => {
|
||||||
|
logger.warn(err, '[chat-run-socket] /destroy interrupt failed for session %s', sessionId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await ctx.bridge.destroy(sessionId).catch((err) => {
|
||||||
|
bridgeReachable = false
|
||||||
|
bridgeError = err instanceof Error ? err.message : String(err)
|
||||||
|
logger.warn(err, '[chat-run-socket] /destroy bridge unavailable for session %s', sessionId)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
updateSessionStats(sessionId)
|
||||||
|
await calcAndUpdateUsage(sessionId, state, (event, payload) => {
|
||||||
|
emitToSession(ctx.nsp, ctx.socket, sessionId, event, payload)
|
||||||
|
})
|
||||||
|
state.isWorking = false
|
||||||
|
state.isAborting = false
|
||||||
|
state.profile = undefined
|
||||||
|
state.abortController = undefined
|
||||||
|
state.runId = undefined
|
||||||
|
state.responseRun = undefined
|
||||||
|
state.activeRunMarker = undefined
|
||||||
|
state.events = []
|
||||||
|
state.queue = []
|
||||||
|
state.bridgePendingAssistantContent = undefined
|
||||||
|
state.bridgePendingReasoningContent = undefined
|
||||||
|
state.bridgeOutput = undefined
|
||||||
|
state.bridgePendingTools = undefined
|
||||||
|
state.bridgeCompressionResults = undefined
|
||||||
|
replaceState(ctx.sessionMap, sessionId, 'session.command', {
|
||||||
|
event: 'session.command',
|
||||||
|
action: 'destroy',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', {
|
||||||
|
event: 'run.queued',
|
||||||
|
session_id: sessionId,
|
||||||
|
queue_length: 0,
|
||||||
|
})
|
||||||
|
emitCommand({
|
||||||
|
action: 'destroy',
|
||||||
|
message: bridgeReachable
|
||||||
|
? (wasWorking ? 'Destroyed bridge agent and stopped the active run.' : 'Destroyed bridge agent.')
|
||||||
|
: `Bridge agent was not reachable; cleared local session state.${bridgeError ? ` (${bridgeError})` : ''}`,
|
||||||
|
destroyed: true,
|
||||||
|
bridgeReachable,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTransientRunState(state: SessionState) {
|
||||||
|
state.events = []
|
||||||
|
state.bridgePendingTools = undefined
|
||||||
|
state.bridgeCompressionResults = undefined
|
||||||
|
state.responseRun = undefined
|
||||||
|
state.activeRunMarker = undefined
|
||||||
|
state.runId = undefined
|
||||||
|
state.abortController = undefined
|
||||||
|
state.isAborting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCommandSession(sessionId: string, ctx: SessionCommandContext) {
|
||||||
|
if (getSession(sessionId)) return
|
||||||
|
createSession({
|
||||||
|
id: sessionId,
|
||||||
|
profile: ctx.profile,
|
||||||
|
source: 'cli',
|
||||||
|
model: ctx.model,
|
||||||
|
title: 'Bridge command',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistCommandMessage(sessionId: string, state: SessionState, content: string) {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const id = addMessage({
|
||||||
|
session_id: sessionId,
|
||||||
|
role: 'command',
|
||||||
|
content,
|
||||||
|
timestamp: now,
|
||||||
|
})
|
||||||
|
state.messages.push({
|
||||||
|
id: id || `command_${now}_${state.messages.length}`,
|
||||||
|
session_id: sessionId,
|
||||||
|
role: 'command',
|
||||||
|
content,
|
||||||
|
timestamp: now,
|
||||||
|
})
|
||||||
|
updateSessionStats(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitToSession(nsp: ReturnType<Server['of']>, socket: Socket, sessionId: string, event: string, payload: any) {
|
||||||
|
const tagged = { ...payload, session_id: sessionId }
|
||||||
|
nsp.to(`session:${sessionId}`).emit(event, tagged)
|
||||||
|
if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) {
|
||||||
|
socket.emit(event, tagged)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ const getConversationDetailMock = vi.fn()
|
|||||||
const getSessionDetailFromDbMock = vi.fn()
|
const getSessionDetailFromDbMock = vi.fn()
|
||||||
const getUsageStatsFromDbMock = vi.fn()
|
const getUsageStatsFromDbMock = vi.fn()
|
||||||
const getSessionMock = vi.fn()
|
const getSessionMock = vi.fn()
|
||||||
|
const localListSessionsMock = vi.fn()
|
||||||
|
const localGetSessionDetailMock = vi.fn()
|
||||||
|
const localSearchSessionsMock = vi.fn()
|
||||||
|
const localDeleteSessionMock = vi.fn()
|
||||||
|
const localRenameSessionMock = 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()
|
||||||
@@ -47,6 +52,11 @@ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
|||||||
// Mock useLocalSessionStore to return false so we test the CLI path
|
// Mock useLocalSessionStore to return false so we test the CLI path
|
||||||
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
|
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
|
||||||
useLocalSessionStore: () => false,
|
useLocalSessionStore: () => false,
|
||||||
|
listSessions: localListSessionsMock,
|
||||||
|
searchSessions: localSearchSessionsMock,
|
||||||
|
getSessionDetail: localGetSessionDetailMock,
|
||||||
|
deleteSession: localDeleteSessionMock,
|
||||||
|
renameSession: localRenameSessionMock,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
|
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
|
||||||
@@ -97,6 +107,11 @@ describe('session conversations controller', () => {
|
|||||||
getSessionDetailFromDbMock.mockReset()
|
getSessionDetailFromDbMock.mockReset()
|
||||||
getUsageStatsFromDbMock.mockReset()
|
getUsageStatsFromDbMock.mockReset()
|
||||||
getSessionMock.mockReset()
|
getSessionMock.mockReset()
|
||||||
|
localListSessionsMock.mockReset()
|
||||||
|
localGetSessionDetailMock.mockReset()
|
||||||
|
localSearchSessionsMock.mockReset()
|
||||||
|
localDeleteSessionMock.mockReset()
|
||||||
|
localRenameSessionMock.mockReset()
|
||||||
getGroupChatServerMock.mockReset()
|
getGroupChatServerMock.mockReset()
|
||||||
getGroupChatServerMock.mockReturnValue(null)
|
getGroupChatServerMock.mockReturnValue(null)
|
||||||
getLocalUsageStatsMock.mockReset()
|
getLocalUsageStatsMock.mockReset()
|
||||||
@@ -106,57 +121,84 @@ describe('session conversations controller', () => {
|
|||||||
getCompressionSnapshotMock.mockReset()
|
getCompressionSnapshotMock.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prefers the DB-backed conversations summary path', async () => {
|
it('lists conversations from the local session store', async () => {
|
||||||
listConversationSummariesFromDbMock.mockResolvedValue([{ id: 'db-conversation' }])
|
localListSessionsMock.mockReturnValue([{
|
||||||
|
id: 'local-conversation',
|
||||||
|
source: 'cli',
|
||||||
|
model: 'gpt-5',
|
||||||
|
title: 'Local',
|
||||||
|
started_at: 1,
|
||||||
|
ended_at: null,
|
||||||
|
last_active: Math.floor(Date.now() / 1000),
|
||||||
|
message_count: 2,
|
||||||
|
tool_call_count: 0,
|
||||||
|
input_tokens: 1,
|
||||||
|
output_tokens: 2,
|
||||||
|
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: 'preview',
|
||||||
|
workspace: null,
|
||||||
|
}])
|
||||||
|
|
||||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||||
const ctx: any = { query: { humanOnly: 'true', limit: '5' }, body: null }
|
const ctx: any = { query: { humanOnly: 'true', limit: '5' }, body: null }
|
||||||
await mod.listConversations(ctx)
|
await mod.listConversations(ctx)
|
||||||
|
|
||||||
expect(listConversationSummariesFromDbMock).toHaveBeenCalledWith({ source: undefined, humanOnly: true, limit: 5 })
|
expect(localListSessionsMock).toHaveBeenCalledWith('default', undefined, 5)
|
||||||
expect(listConversationSummariesMock).not.toHaveBeenCalled()
|
expect(listConversationSummariesMock).not.toHaveBeenCalled()
|
||||||
expect(ctx.body).toEqual({ sessions: [{ id: 'db-conversation' }] })
|
expect(ctx.body.sessions[0]).toMatchObject({ id: 'local-conversation', source: 'cli', title: 'Local' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the CLI-export conversations summary path when the DB query fails', async () => {
|
it('propagates local session store errors for conversation summaries', async () => {
|
||||||
listConversationSummariesFromDbMock.mockRejectedValue(new Error('db unavailable'))
|
localListSessionsMock.mockImplementation(() => {
|
||||||
listConversationSummariesMock.mockResolvedValue([{ id: 'fallback-conversation' }])
|
throw new Error('db unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||||
const ctx: any = { query: { humanOnly: 'false' }, body: null }
|
const ctx: any = { query: { humanOnly: 'false' }, body: null }
|
||||||
await mod.listConversations(ctx)
|
await expect(mod.listConversations(ctx)).rejects.toThrow('db unavailable')
|
||||||
|
|
||||||
expect(loggerWarnMock).toHaveBeenCalled()
|
|
||||||
expect(listConversationSummariesMock).toHaveBeenCalledWith({ source: undefined, humanOnly: false, limit: undefined })
|
|
||||||
expect(ctx.body).toEqual({ sessions: [{ id: 'fallback-conversation' }] })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prefers the DB-backed conversation detail path', async () => {
|
it('gets conversation messages from the local session store', async () => {
|
||||||
getConversationDetailFromDbMock.mockResolvedValue({ session_id: 'root', messages: [], visible_count: 0, thread_session_count: 1 })
|
localGetSessionDetailMock.mockReturnValue({
|
||||||
|
id: 'root',
|
||||||
|
messages: [
|
||||||
|
{ id: 1, session_id: 'root', role: 'user', content: 'hello', timestamp: 1 },
|
||||||
|
{ id: 2, session_id: 'root', role: 'command', content: '/usage', timestamp: 2 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||||
const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'true' }, body: null }
|
const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'true' }, body: null }
|
||||||
await mod.getConversationMessages(ctx)
|
await mod.getConversationMessages(ctx)
|
||||||
|
|
||||||
expect(getConversationDetailFromDbMock).toHaveBeenCalledWith('root', { source: undefined, humanOnly: true })
|
expect(localGetSessionDetailMock).toHaveBeenCalledWith('root')
|
||||||
expect(getConversationDetailMock).not.toHaveBeenCalled()
|
expect(getConversationDetailMock).not.toHaveBeenCalled()
|
||||||
expect(ctx.body).toEqual({ session_id: 'root', messages: [], visible_count: 0, thread_session_count: 1 })
|
expect(ctx.body).toEqual({
|
||||||
|
session_id: 'root',
|
||||||
|
messages: [{ id: 1, session_id: 'root', role: 'user', content: 'hello', timestamp: 1 }],
|
||||||
|
visible_count: 1,
|
||||||
|
thread_session_count: 1,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the CLI-export conversation detail path when the DB query throws', async () => {
|
it('returns 404 when local conversation detail is missing', async () => {
|
||||||
getConversationDetailFromDbMock.mockRejectedValue(new Error('db unavailable'))
|
localGetSessionDetailMock.mockReturnValue(null)
|
||||||
getConversationDetailMock.mockResolvedValue({ session_id: 'root', messages: [{ id: 1 }], visible_count: 1, thread_session_count: 1 })
|
|
||||||
|
|
||||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||||
const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'false' }, body: null }
|
const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'false' }, body: null }
|
||||||
await mod.getConversationMessages(ctx)
|
await mod.getConversationMessages(ctx)
|
||||||
|
|
||||||
expect(loggerWarnMock).toHaveBeenCalled()
|
expect(ctx.status).toBe(404)
|
||||||
expect(getConversationDetailMock).toHaveBeenCalledWith('root', { source: undefined, humanOnly: false })
|
expect(ctx.body).toEqual({ error: 'Conversation not found' })
|
||||||
expect(ctx.body).toEqual({ session_id: 'root', messages: [{ id: 1 }], visible_count: 1, thread_session_count: 1 })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('merges native state.db usage analytics with local Web UI usage for the requested period', async () => {
|
it('returns native state.db usage analytics for the requested period', async () => {
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
getLocalUsageStatsMock.mockReturnValue({
|
getLocalUsageStatsMock.mockReturnValue({
|
||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
@@ -193,34 +235,33 @@ describe('session conversations controller', () => {
|
|||||||
const ctx: any = { query: { days: '2' }, body: null }
|
const ctx: any = { query: { days: '2' }, body: null }
|
||||||
await mod.usageStats(ctx)
|
await mod.usageStats(ctx)
|
||||||
|
|
||||||
expect(getLocalUsageStatsMock).toHaveBeenCalledWith('default', 2)
|
expect(getLocalUsageStatsMock).not.toHaveBeenCalled()
|
||||||
expect(getUsageStatsFromDbMock).toHaveBeenCalledWith(2)
|
expect(getUsageStatsFromDbMock).toHaveBeenCalledWith(2)
|
||||||
expect(ctx.body).toMatchObject({
|
expect(ctx.body).toMatchObject({
|
||||||
total_input_tokens: 30,
|
total_input_tokens: 20,
|
||||||
total_output_tokens: 15,
|
total_output_tokens: 10,
|
||||||
total_cache_read_tokens: 6,
|
total_cache_read_tokens: 4,
|
||||||
total_cache_write_tokens: 3,
|
total_cache_write_tokens: 2,
|
||||||
total_reasoning_tokens: 9,
|
total_reasoning_tokens: 6,
|
||||||
total_sessions: 3,
|
total_sessions: 2,
|
||||||
total_cost: 0.02,
|
total_cost: 0.02,
|
||||||
total_api_calls: 7,
|
total_api_calls: 7,
|
||||||
period_days: 2,
|
period_days: 2,
|
||||||
})
|
})
|
||||||
expect(ctx.body.model_usage).toEqual([
|
expect(ctx.body.model_usage).toEqual([
|
||||||
{ model: 'hermes-model', input_tokens: 20, output_tokens: 10, cache_read_tokens: 4, cache_write_tokens: 2, reasoning_tokens: 6, sessions: 2 },
|
{ model: 'hermes-model', input_tokens: 20, output_tokens: 10, cache_read_tokens: 4, cache_write_tokens: 2, reasoning_tokens: 6, sessions: 2 },
|
||||||
{ model: 'local-model', input_tokens: 10, output_tokens: 5, cache_read_tokens: 2, cache_write_tokens: 1, reasoning_tokens: 3, sessions: 1 },
|
|
||||||
])
|
])
|
||||||
expect(ctx.body.daily_usage.find((row: any) => row.date === today)).toMatchObject({
|
expect(ctx.body.daily_usage.find((row: any) => row.date === today)).toMatchObject({
|
||||||
input_tokens: 30,
|
input_tokens: 20,
|
||||||
output_tokens: 15,
|
output_tokens: 10,
|
||||||
cache_read_tokens: 6,
|
cache_read_tokens: 4,
|
||||||
cache_write_tokens: 3,
|
cache_write_tokens: 2,
|
||||||
sessions: 3,
|
sessions: 2,
|
||||||
cost: 0.02,
|
cost: 0.02,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps blank model usage under an unknown bucket', async () => {
|
it('keeps blank model usage as returned by state.db analytics', async () => {
|
||||||
getLocalUsageStatsMock.mockReturnValue({
|
getLocalUsageStatsMock.mockReturnValue({
|
||||||
input_tokens: 3,
|
input_tokens: 3,
|
||||||
output_tokens: 1,
|
output_tokens: 1,
|
||||||
@@ -253,14 +294,14 @@ describe('session conversations controller', () => {
|
|||||||
await mod.usageStats(ctx)
|
await mod.usageStats(ctx)
|
||||||
|
|
||||||
expect(ctx.body.model_usage).toEqual([
|
expect(ctx.body.model_usage).toEqual([
|
||||||
{ model: 'unknown', input_tokens: 5, output_tokens: 2, cache_read_tokens: 3, cache_write_tokens: 1, reasoning_tokens: 0, sessions: 2 },
|
{ model: ' ', input_tokens: 2, output_tokens: 1, cache_read_tokens: 1, cache_write_tokens: 1, reasoning_tokens: 0, sessions: 1 },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
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' }] }
|
||||||
getSessionDetailFromDbMock.mockResolvedValue(sessionData)
|
localGetSessionDetailMock.mockReturnValue(sessionData)
|
||||||
|
|
||||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||||
const setMock = vi.fn()
|
const setMock = vi.fn()
|
||||||
@@ -268,7 +309,7 @@ describe('session conversations controller', () => {
|
|||||||
|
|
||||||
await mod.exportSession(ctx)
|
await mod.exportSession(ctx)
|
||||||
|
|
||||||
expect(getSessionDetailFromDbMock).toHaveBeenCalledWith('abc-123')
|
expect(localGetSessionDetailMock).toHaveBeenCalledWith('abc-123')
|
||||||
expect(setMock).toHaveBeenCalledWith('Content-Disposition', expect.stringContaining('abc-123'))
|
expect(setMock).toHaveBeenCalledWith('Content-Disposition', expect.stringContaining('abc-123'))
|
||||||
expect(setMock).toHaveBeenCalledWith('Content-Type', 'application/json')
|
expect(setMock).toHaveBeenCalledWith('Content-Type', 'application/json')
|
||||||
expect(ctx.status).toBeUndefined()
|
expect(ctx.status).toBeUndefined()
|
||||||
@@ -284,7 +325,7 @@ describe('session conversations controller', () => {
|
|||||||
{ id: 2, role: 'assistant', content: 'hi', timestamp: 1700000001 },
|
{ id: 2, role: 'assistant', content: 'hi', timestamp: 1700000001 },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
getSessionDetailFromDbMock.mockResolvedValue(sessionData)
|
localGetSessionDetailMock.mockReturnValue(sessionData)
|
||||||
|
|
||||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||||
const setMock = vi.fn()
|
const setMock = vi.fn()
|
||||||
@@ -301,7 +342,7 @@ describe('session conversations controller', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns 404 when session not found', async () => {
|
it('returns 404 when session not found', async () => {
|
||||||
getSessionDetailFromDbMock.mockResolvedValue(null)
|
localGetSessionDetailMock.mockReturnValue(null)
|
||||||
getSessionMock.mockResolvedValue(null)
|
getSessionMock.mockResolvedValue(null)
|
||||||
|
|
||||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||||
@@ -315,8 +356,7 @@ describe('session conversations controller', () => {
|
|||||||
|
|
||||||
it('falls back to CLI when DB query fails', async () => {
|
it('falls back to CLI when DB query fails', async () => {
|
||||||
const sessionData = { id: 'cli-123', title: 'CLI Session', messages: [] }
|
const sessionData = { id: 'cli-123', title: 'CLI Session', messages: [] }
|
||||||
getSessionDetailFromDbMock.mockRejectedValue(new Error('db unavailable'))
|
localGetSessionDetailMock.mockReturnValue(sessionData)
|
||||||
getSessionMock.mockResolvedValue(sessionData)
|
|
||||||
|
|
||||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||||
const setMock = vi.fn()
|
const setMock = vi.fn()
|
||||||
@@ -324,7 +364,7 @@ describe('session conversations controller', () => {
|
|||||||
|
|
||||||
await mod.exportSession(ctx)
|
await mod.exportSession(ctx)
|
||||||
|
|
||||||
expect(getSessionMock).toHaveBeenCalledWith('cli-123')
|
expect(localGetSessionDetailMock).toHaveBeenCalledWith('cli-123')
|
||||||
expect(JSON.parse(ctx.body)).toMatchObject({ id: 'cli-123' })
|
expect(JSON.parse(ctx.body)).toMatchObject({ id: 'cli-123' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type UpdateControllerMocks = {
|
|||||||
async function loadUpdateController(overrides: Partial<UpdateControllerMocks> = {}) {
|
async function loadUpdateController(overrides: Partial<UpdateControllerMocks> = {}) {
|
||||||
const execFileSync = overrides.execFileSync ?? vi.fn().mockReturnValue('updated')
|
const execFileSync = overrides.execFileSync ?? vi.fn().mockReturnValue('updated')
|
||||||
const unref = overrides.unref ?? vi.fn()
|
const unref = overrides.unref ?? vi.fn()
|
||||||
const spawn = overrides.spawn ?? vi.fn(() => ({ unref }))
|
const spawn = overrides.spawn ?? vi.fn(() => ({ unref, on: vi.fn() }))
|
||||||
const existsSync = overrides.existsSync ?? vi.fn(() => true)
|
const existsSync = overrides.existsSync ?? vi.fn(() => true)
|
||||||
|
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
@@ -80,7 +80,11 @@ describe('update controller', () => {
|
|||||||
const globalPrefix = getNodePrefix()
|
const globalPrefix = getNodePrefix()
|
||||||
const cliScript = getGlobalCliScript(globalPrefix)
|
const cliScript = getGlobalCliScript(globalPrefix)
|
||||||
const execFileSync = vi.fn((_command: string, args: string[]) => {
|
const execFileSync = vi.fn((_command: string, args: string[]) => {
|
||||||
if (args[1] === 'prefix') return globalPrefix
|
if (args[1] === 'root') {
|
||||||
|
return process.platform === 'win32'
|
||||||
|
? join(globalPrefix, 'node_modules')
|
||||||
|
: join(globalPrefix, 'lib', 'node_modules')
|
||||||
|
}
|
||||||
return 'updated'
|
return 'updated'
|
||||||
})
|
})
|
||||||
const { handleUpdate, mocks } = await loadUpdateController({ execFileSync })
|
const { handleUpdate, mocks } = await loadUpdateController({ execFileSync })
|
||||||
@@ -107,7 +111,7 @@ describe('update controller', () => {
|
|||||||
|
|
||||||
expect(mocks.execFileSync).toHaveBeenCalledWith(
|
expect(mocks.execFileSync).toHaveBeenCalledWith(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[npmCli, 'prefix', '-g'],
|
[npmCli, 'root', '-g'],
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
@@ -125,7 +129,6 @@ describe('update controller', () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(mocks.unref).toHaveBeenCalledOnce()
|
expect(mocks.unref).toHaveBeenCalledOnce()
|
||||||
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the default port when PORT is not set', async () => {
|
it('falls back to the default port when PORT is not set', async () => {
|
||||||
@@ -143,6 +146,29 @@ describe('update controller', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not log a restart error when the restart helper exits successfully', async () => {
|
||||||
|
const handlers = new Map<string, (...args: any[]) => void>()
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
const unref = vi.fn()
|
||||||
|
const restart = {
|
||||||
|
unref,
|
||||||
|
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
|
||||||
|
handlers.set(event, handler)
|
||||||
|
return restart
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const spawn = vi.fn(() => restart)
|
||||||
|
const { handleUpdate } = await loadUpdateController({ spawn, unref })
|
||||||
|
const ctx = createMockCtx()
|
||||||
|
|
||||||
|
await handleUpdate(ctx)
|
||||||
|
vi.runAllTimers()
|
||||||
|
handlers.get('exit')?.(0, null)
|
||||||
|
|
||||||
|
expect(errorSpy).not.toHaveBeenCalled()
|
||||||
|
errorSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
it('returns a 500 with stderr when installation fails', async () => {
|
it('returns a 500 with stderr when installation fails', async () => {
|
||||||
const execFileSync = vi.fn(() => {
|
const execFileSync = vi.fn(() => {
|
||||||
const error = new Error('install failed') as Error & { stderr?: string }
|
const error = new Error('install failed') as Error & { stderr?: string }
|
||||||
@@ -160,19 +186,4 @@ describe('update controller', () => {
|
|||||||
expect(exitSpy).not.toHaveBeenCalled()
|
expect(exitSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fails closed instead of falling back to PATH npm when the current Node install has no npm CLI', async () => {
|
|
||||||
const { handleUpdate, mocks } = await loadUpdateController({ existsSync: vi.fn(() => false) })
|
|
||||||
const ctx = createMockCtx()
|
|
||||||
|
|
||||||
await handleUpdate(ctx)
|
|
||||||
|
|
||||||
expect(ctx.status).toBe(500)
|
|
||||||
expect(ctx.body).toEqual({
|
|
||||||
success: false,
|
|
||||||
message: expect.stringContaining(`Unable to locate npm CLI for ${process.execPath}`),
|
|
||||||
})
|
|
||||||
expect(mocks.execFileSync).not.toHaveBeenCalled()
|
|
||||||
expect(mocks.spawn).not.toHaveBeenCalled()
|
|
||||||
expect(exitSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ describe('native-style Hermes usage analytics DB aggregation', () => {
|
|||||||
profileDir = null
|
profileDir = null
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sums direct state.db rows in the period while excluding local api_server copies', async () => {
|
it('sums direct state.db rows in the period', async () => {
|
||||||
const now = 1_700_000_000
|
const now = 1_700_000_000
|
||||||
profileDir = createStateDb(true)
|
profileDir = createStateDb(true)
|
||||||
profileMock.getActiveProfileDir.mockReturnValue(profileDir)
|
profileMock.getActiveProfileDir.mockReturnValue(profileDir)
|
||||||
@@ -190,17 +190,17 @@ describe('native-style Hermes usage analytics DB aggregation', () => {
|
|||||||
const result = await mod.getUsageStatsFromDb(30, now)
|
const result = await mod.getUsageStatsFromDb(30, now)
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
input_tokens: 138,
|
input_tokens: 638,
|
||||||
output_tokens: 75,
|
output_tokens: 575,
|
||||||
cache_read_tokens: 16,
|
cache_read_tokens: 16,
|
||||||
cache_write_tokens: 3,
|
cache_write_tokens: 3,
|
||||||
reasoning_tokens: 7,
|
reasoning_tokens: 7,
|
||||||
sessions: 4,
|
sessions: 5,
|
||||||
total_api_calls: 3,
|
total_api_calls: 8,
|
||||||
})
|
})
|
||||||
expect(result.cost).toBeCloseTo(0.043)
|
expect(result.cost).toBeCloseTo(5.043)
|
||||||
expect(result.by_model).toEqual([
|
expect(result.by_model).toEqual([
|
||||||
{ model: 'gpt-5', input_tokens: 107, output_tokens: 53, cache_read_tokens: 11, cache_write_tokens: 2, reasoning_tokens: 5, sessions: 2 },
|
{ model: 'gpt-5', input_tokens: 607, output_tokens: 553, cache_read_tokens: 11, cache_write_tokens: 2, reasoning_tokens: 5, sessions: 3 },
|
||||||
{ model: 'tool-model', input_tokens: 30, output_tokens: 20, cache_read_tokens: 5, cache_write_tokens: 1, reasoning_tokens: 2, sessions: 1 },
|
{ model: 'tool-model', input_tokens: 30, output_tokens: 20, cache_read_tokens: 5, cache_write_tokens: 1, reasoning_tokens: 2, sessions: 1 },
|
||||||
])
|
])
|
||||||
expect(result.by_day).toHaveLength(2)
|
expect(result.by_day).toHaveLength(2)
|
||||||
@@ -216,14 +216,14 @@ describe('native-style Hermes usage analytics DB aggregation', () => {
|
|||||||
})
|
})
|
||||||
expect(result.by_day[1]).toMatchObject({
|
expect(result.by_day[1]).toMatchObject({
|
||||||
date: day(now),
|
date: day(now),
|
||||||
input_tokens: 131,
|
input_tokens: 631,
|
||||||
output_tokens: 72,
|
output_tokens: 572,
|
||||||
cache_read_tokens: 15,
|
cache_read_tokens: 15,
|
||||||
cache_write_tokens: 3,
|
cache_write_tokens: 3,
|
||||||
sessions: 3,
|
sessions: 4,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
})
|
})
|
||||||
expect(result.by_day[1].cost).toBeCloseTo(0.038)
|
expect(result.by_day[1].cost).toBeCloseTo(5.038)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps analytics working against older state.db schemas without api_call_count', async () => {
|
it('keeps analytics working against older state.db schemas without api_call_count', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user