feat: add bridge session commands (#743)

This commit is contained in:
ekko
2026-05-15 12:04:03 +08:00
committed by GitHub
parent 13fad02db8
commit 48dcaee6c2
22 changed files with 1180 additions and 88 deletions
@@ -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: