feat: add bridge session commands (#743)
This commit is contained in:
@@ -77,6 +77,7 @@ const sessionEventHandlers = new Map<string, {
|
||||
onAbortStarted: (event: RunEvent) => void
|
||||
onAbortCompleted: (event: RunEvent) => void
|
||||
onUsageUpdated: (event: RunEvent) => void
|
||||
onSessionCommand?: (event: RunEvent) => void
|
||||
onRunQueued?: (event: RunEvent) => void
|
||||
onApprovalRequested?: (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 {
|
||||
const sid = event.session_id
|
||||
if (!sid) return
|
||||
@@ -334,6 +345,7 @@ export function registerSessionHandlers(
|
||||
onAbortStarted: (event: RunEvent) => void
|
||||
onAbortCompleted: (event: RunEvent) => void
|
||||
onUsageUpdated: (event: RunEvent) => void
|
||||
onSessionCommand?: (event: RunEvent) => void
|
||||
onRunQueued?: (event: RunEvent) => void
|
||||
onApprovalRequested?: (event: RunEvent) => void
|
||||
onApprovalResolved?: (event: RunEvent) => void
|
||||
@@ -436,6 +448,7 @@ export function connectChatRun(): Socket {
|
||||
|
||||
// Usage events
|
||||
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
|
||||
chatRunSocket.on('session.command', globalSessionCommandHandler)
|
||||
|
||||
globalListenersRegistered = true
|
||||
}
|
||||
@@ -565,6 +578,14 @@ export function startRunViaSocket(
|
||||
if (closed) return
|
||||
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) => {
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface SessionSearchResult extends SessionSummary {
|
||||
export interface HermesMessage {
|
||||
id: number
|
||||
session_id: string
|
||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||
role: 'user' | 'assistant' | 'system' | 'tool' | 'command'
|
||||
content: string
|
||||
tool_call_id: string | null
|
||||
tool_calls: any[] | null
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { fetchContextLength } from '@/api/hermes/sessions'
|
||||
import { setModelContext } from '@/api/hermes/model-context'
|
||||
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'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
@@ -14,12 +14,37 @@ const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const inputText = ref('')
|
||||
const textareaRef = ref<HTMLTextAreaElement>()
|
||||
const commandDropdownRef = ref<HTMLDivElement>()
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const attachments = ref<Attachment[]>([])
|
||||
const isDragging = ref(false)
|
||||
const dragCounter = ref(0)
|
||||
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
|
||||
|
||||
@@ -73,6 +98,44 @@ watch(autoPlaySpeech, (value) => {
|
||||
|
||||
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 ---
|
||||
|
||||
const contextLength = ref(200000)
|
||||
@@ -231,6 +294,7 @@ function handleSend() {
|
||||
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
|
||||
inputText.value = ''
|
||||
attachments.value = []
|
||||
slashActive.value = false
|
||||
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.style.height = 'auto'
|
||||
@@ -244,6 +308,7 @@ function handleCompositionStart() {
|
||||
function handleCompositionEnd() {
|
||||
requestAnimationFrame(() => {
|
||||
isComposing.value = false
|
||||
updateSlashState()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -252,6 +317,31 @@ function isImeEnter(e: KeyboardEvent): boolean {
|
||||
}
|
||||
|
||||
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 (isImeEnter(e)) return
|
||||
|
||||
@@ -260,13 +350,34 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const el = e.target as HTMLTextAreaElement
|
||||
if (!isComposing.value) updateSlashState()
|
||||
// 用户手动拖拽自定义高度时,不覆盖
|
||||
if (textareaHeight.value !== null) return
|
||||
const el = e.target as HTMLTextAreaElement
|
||||
el.style.height = 'auto'
|
||||
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) {
|
||||
const idx = attachments.value.findIndex(a => a.id === id)
|
||||
if (idx !== -1) {
|
||||
@@ -396,6 +507,26 @@ function isImage(type: string): boolean {
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
></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">
|
||||
<NButton
|
||||
v-if="chatStore.isStreaming"
|
||||
@@ -685,6 +816,75 @@ function isImage(type: string): boolean {
|
||||
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
|
||||
.input-wrapper.drag-over {
|
||||
border-color: var(--accent-info);
|
||||
|
||||
@@ -26,6 +26,20 @@ const { t } = useI18n();
|
||||
const toast = useMessage();
|
||||
|
||||
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
|
||||
const contentBlocks = computed(() => {
|
||||
@@ -572,7 +586,15 @@ onBeforeUnmount(() => {
|
||||
class="msg-avatar"
|
||||
/>
|
||||
<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-for="att in message.attachments"
|
||||
@@ -703,6 +725,29 @@ onBeforeUnmount(() => {
|
||||
: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></span><span></span><span></span>
|
||||
</span>
|
||||
@@ -806,6 +851,10 @@ onBeforeUnmount(() => {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.command {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.highlight {
|
||||
.message-bubble {
|
||||
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);
|
||||
}
|
||||
|
||||
&.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 {
|
||||
box-shadow:
|
||||
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 {
|
||||
0% {
|
||||
box-shadow:
|
||||
|
||||
@@ -121,6 +121,23 @@ export default {
|
||||
contextEditFailed: 'Aktualisierung fehlgeschlagen',
|
||||
emptyState: 'Starten Sie eine Konversation mit Hermes Agent',
|
||||
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',
|
||||
messageQueue: 'Nachrichtenwarteschlange',
|
||||
removeQueuedMessage: 'Nachricht aus Warteschlange entfernen',
|
||||
|
||||
@@ -134,6 +134,23 @@ export default {
|
||||
emptyState: 'Start a conversation with Hermes Agent',
|
||||
cliEmptyState: 'Start a CLI chat session',
|
||||
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',
|
||||
autoPlaySpeech: 'Auto-play voice',
|
||||
messageQueue: 'Message queue',
|
||||
|
||||
@@ -121,6 +121,23 @@ export default {
|
||||
contextEditFailed: 'Error en la actualización',
|
||||
emptyState: 'Inicia una conversacion con Hermes Agent',
|
||||
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',
|
||||
messageQueue: 'Cola de mensajes',
|
||||
removeQueuedMessage: 'Quitar mensaje de la cola',
|
||||
|
||||
@@ -121,6 +121,23 @@ export default {
|
||||
contextEditFailed: 'Échec de la mise à jour',
|
||||
emptyState: 'Demarrer une conversation avec Hermes Agent',
|
||||
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',
|
||||
messageQueue: 'File de messages',
|
||||
removeQueuedMessage: 'Retirer le message de la file',
|
||||
|
||||
@@ -121,6 +121,23 @@ export default {
|
||||
contextEditFailed: '更新に失敗しました',
|
||||
emptyState: 'Hermes Agent と会話を開始しましょう',
|
||||
inputPlaceholder: 'メッセージを入力... (Enter で送信、Shift+Enter で改行)',
|
||||
slashCommandArgs: {
|
||||
message: '<メッセージ>',
|
||||
title: '<タイトル>',
|
||||
text: '<テキスト>',
|
||||
},
|
||||
slashCommands: {
|
||||
usage: '現在のセッション使用量を計算',
|
||||
status: 'セッション状態とキューを表示',
|
||||
abort: '実行中の Bridge を停止',
|
||||
queue: '実行中の処理の後ろにメッセージをキュー追加',
|
||||
clear: '現在の表示をクリア',
|
||||
clearHistory: 'このセッションの保存済みメッセージ履歴を削除',
|
||||
title: 'このセッション名を変更',
|
||||
compress: 'アイドル時にコンテキスト圧縮を実行',
|
||||
steer: '実行中の Bridge に誘導テキストを送信',
|
||||
destroy: 'このセッションの Bridge Agent を解放',
|
||||
},
|
||||
attachFiles: 'ファイルを添付',
|
||||
messageQueue: 'メッセージキュー',
|
||||
removeQueuedMessage: 'キューのメッセージを削除',
|
||||
|
||||
@@ -121,6 +121,23 @@ export default {
|
||||
contextEditFailed: '업데이트 실패',
|
||||
emptyState: 'Hermes Agent와 대화를 시작하세요',
|
||||
inputPlaceholder: '메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)',
|
||||
slashCommandArgs: {
|
||||
message: '<메시지>',
|
||||
title: '<제목>',
|
||||
text: '<텍스트>',
|
||||
},
|
||||
slashCommands: {
|
||||
usage: '현재 세션 사용량 계산',
|
||||
status: '세션 상태와 대기열 표시',
|
||||
abort: '활성 Bridge 실행 중지',
|
||||
queue: '활성 실행 뒤에 메시지 대기열 추가',
|
||||
clear: '현재 표시 내용 지우기',
|
||||
clearHistory: '이 세션의 저장된 메시지 기록 삭제',
|
||||
title: '이 세션 이름 변경',
|
||||
compress: '유휴 상태에서 컨텍스트 압축 실행',
|
||||
steer: '활성 Bridge 실행에 지시 텍스트 보내기',
|
||||
destroy: '이 세션의 Bridge Agent 해제',
|
||||
},
|
||||
attachFiles: '파일 첨부',
|
||||
messageQueue: '메시지 대기열',
|
||||
removeQueuedMessage: '대기열 메시지 제거',
|
||||
|
||||
@@ -121,6 +121,23 @@ export default {
|
||||
contextEditFailed: 'Falha na atualização',
|
||||
emptyState: 'Inicie uma conversa com o Hermes Agent',
|
||||
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',
|
||||
messageQueue: 'Fila de mensagens',
|
||||
removeQueuedMessage: 'Remover mensagem da fila',
|
||||
|
||||
@@ -133,6 +133,23 @@ export default {
|
||||
contextEditFailed: '更新失敗',
|
||||
emptyState: '開始與 Hermes Agent 對話',
|
||||
inputPlaceholder: '輸入訊息... (Enter 發送,Shift+Enter 換行)',
|
||||
slashCommandArgs: {
|
||||
message: '<訊息>',
|
||||
title: '<標題>',
|
||||
text: '<文字>',
|
||||
},
|
||||
slashCommands: {
|
||||
usage: '計算目前會話用量',
|
||||
status: '查看會話狀態和佇列',
|
||||
abort: '停止目前 Bridge 執行',
|
||||
queue: '將訊息加入目前執行後的佇列',
|
||||
clear: '清空目前顯示內容',
|
||||
clearHistory: '刪除目前會話已儲存的訊息歷史',
|
||||
title: '重新命名目前會話',
|
||||
compress: '空閒時觸發上下文壓縮',
|
||||
steer: '向目前 Bridge 執行傳送引導文字',
|
||||
destroy: '釋放目前會話的 Bridge Agent',
|
||||
},
|
||||
attachFiles: '新增附件',
|
||||
autoPlaySpeech: '自動播放語音',
|
||||
messageQueue: '訊息佇列',
|
||||
|
||||
@@ -134,6 +134,23 @@ export default {
|
||||
emptyState: '开始与 Hermes Agent 对话',
|
||||
cliEmptyState: '开始 CLI 对话',
|
||||
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
|
||||
slashCommandArgs: {
|
||||
message: '<消息>',
|
||||
title: '<标题>',
|
||||
text: '<文本>',
|
||||
},
|
||||
slashCommands: {
|
||||
usage: '计算当前会话用量',
|
||||
status: '查看会话状态和队列',
|
||||
abort: '停止当前 Bridge 运行',
|
||||
queue: '把消息加入当前运行后的队列',
|
||||
clear: '清空当前显示内容',
|
||||
clearHistory: '删除当前会话已入库的消息历史',
|
||||
title: '重命名当前会话',
|
||||
compress: '空闲时触发上下文压缩',
|
||||
steer: '向当前 Bridge 运行发送引导文本',
|
||||
destroy: '释放当前会话的 Bridge Agent',
|
||||
},
|
||||
attachFiles: '添加附件',
|
||||
autoPlaySpeech: '自动播放语音',
|
||||
messageQueue: '消息队列',
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface Attachment {
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||
role: 'user' | 'assistant' | 'system' | 'tool' | 'command'
|
||||
content: string
|
||||
timestamp: number
|
||||
toolName?: string
|
||||
@@ -41,6 +41,9 @@ export interface Message {
|
||||
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
|
||||
reasoning?: string
|
||||
queued?: boolean
|
||||
systemType?: 'command' | 'error'
|
||||
commandAction?: string
|
||||
commandData?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PendingApproval {
|
||||
@@ -212,13 +215,14 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normal user/assistant messages
|
||||
// Normal user/assistant/command messages
|
||||
result.push({
|
||||
id: String(msg.id),
|
||||
role: msg.role,
|
||||
content: msg.content || '',
|
||||
timestamp: Math.round(msg.timestamp * 1000),
|
||||
reasoning: msg.reasoning ? msg.reasoning : undefined,
|
||||
systemType: msg.role === 'command' ? 'command' : undefined,
|
||||
})
|
||||
}
|
||||
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) {
|
||||
const queue = queuedUserMessages.value.get(sessionId) || []
|
||||
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
|
||||
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 = {
|
||||
id: uid(),
|
||||
role: 'user',
|
||||
role: isBridgeSlashCommand ? 'command' : 'user',
|
||||
content: content.trim(),
|
||||
timestamp: Date.now(),
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
queued: shouldQueue,
|
||||
systemType: isBridgeSlashCommand ? 'command' : undefined,
|
||||
}
|
||||
|
||||
if (!shouldQueue) {
|
||||
@@ -897,6 +968,11 @@ export const useChatStore = defineStore('chat', () => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'session.command': {
|
||||
handleSessionCommandEvent(evt)
|
||||
break
|
||||
}
|
||||
|
||||
case 'compression.started': {
|
||||
setCompressionState({
|
||||
compressing: true,
|
||||
@@ -1272,7 +1348,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
undefined,
|
||||
)
|
||||
|
||||
streamStates.value.set(sid, ctrl)
|
||||
if (!isBridgeSlashCommand || !wasLiveBeforeSend) {
|
||||
streamStates.value.set(sid, ctrl)
|
||||
}
|
||||
} catch (err: any) {
|
||||
addMessage(sid, {
|
||||
id: uid(),
|
||||
@@ -1333,6 +1411,11 @@ export const useChatStore = defineStore('chat', () => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'session.command': {
|
||||
handleSessionCommandEvent(evt)
|
||||
break
|
||||
}
|
||||
|
||||
case 'run.started':
|
||||
setAbortState(null)
|
||||
runProducedAssistantText = false
|
||||
@@ -1685,6 +1768,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
onAbortStarted: (evt) => handleEvent(evt),
|
||||
onAbortCompleted: (evt) => handleEvent(evt),
|
||||
onUsageUpdated: (evt) => handleEvent(evt),
|
||||
onSessionCommand: (evt) => handleEvent(evt),
|
||||
onRunQueued: (evt) => handleEvent(evt),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user