feat: add bridge session commands (#743)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user