Fix bridge history, profile models, and Windows gateway handling (#845)

* feat: support profile-aware group chat bridge flows

* feat: route cron jobs through hermes cli

* Fix group chat routing and isolate bridge tests

* Add Grok image-to-video media skill

* Default Grok videos to media directory

* Fix bridge profile fallback and cron repeat clearing

* Refine bridge chat and gateway platform handling

* Filter bridge tool-call text deltas

* Preserve structured bridge chat history

* Prepare beta release build artifacts

* Fix Windows run profile resolution

* Fix Windows path compatibility checks

* Fix profile-scoped model page display

* Hide Windows subprocess windows for jobs and updates

* Hide Windows file backend subprocess windows

* Avoid Windows gateway restart lock conflicts

* Treat Windows gateway lock as running on startup

* Force release Windows gateway lock on restart

* Tighten Windows gateway lock cleanup

* Update chat e2e source expectation

* Bump package version to 0.5.30

---------

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
ekko
2026-05-19 16:09:59 +08:00
committed by GitHub
co-authored by Codex
parent 3d74d78698
commit 9a9416c99c
129 changed files with 7016 additions and 1837 deletions
@@ -1,17 +1,38 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton } from 'naive-ui'
import { NButton, NSwitch, NTooltip } from 'naive-ui'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import type { Attachment } from '@/stores/hermes/chat'
const { t } = useI18n()
const emit = defineEmits<{ send: [content: string] }>()
const emit = defineEmits<{ send: [content: string, attachments?: Attachment[]] }>()
const store = useGroupChatStore()
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const dropdownRef = ref<HTMLDivElement>()
const fileInputRef = ref<HTMLInputElement>()
const attachments = ref<Attachment[]>([])
const isDragging = ref(false)
const dragCounter = ref(0)
const isComposing = ref(false)
const autoPlaySpeech = ref(false)
onMounted(() => {
const saved = localStorage.getItem('autoPlaySpeech')
if (saved !== null) {
autoPlaySpeech.value = saved === 'true'
store.setAutoPlaySpeech(autoPlaySpeech.value)
}
})
watch(autoPlaySpeech, (value) => {
localStorage.setItem('autoPlaySpeech', String(value))
store.setAutoPlaySpeech(value)
})
// 自定义高度拖拽
const textareaHeight = ref<number | null>(null)
@@ -58,7 +79,7 @@ const filteredAgents = computed(() => {
return store.agents.filter(a => a.name.toLowerCase().includes(query))
})
const canSend = computed(() => !!inputText.value.trim())
const canSend = computed(() => !!inputText.value.trim() || attachments.value.length > 0)
// ─── Scroll active item into view ──────────────────────
@@ -199,10 +220,11 @@ function handleKeydown(e: KeyboardEvent) {
function handleSend() {
const content = inputText.value.trim()
if (!content) return
if (!content && attachments.value.length === 0) return
emit('send', content)
emit('send', content, attachments.value.length > 0 ? attachments.value : undefined)
inputText.value = ''
attachments.value = []
mentionActive.value = false
// 发送后重置到自定义高度(不清除拖拽状态)
}
@@ -256,11 +278,147 @@ function handleCompositionEnd() {
updateMentionState()
})
}
function addFile(file: File) {
if (attachments.value.find(a => a.name === file.name)) return
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
attachments.value.push({
id,
name: file.name,
type: file.type,
size: file.size,
url: URL.createObjectURL(file),
file,
})
}
function handleAttachClick() {
fileInputRef.value?.click()
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
for (const file of input.files) addFile(file)
input.value = ''
}
function handlePaste(e: ClipboardEvent) {
const items = Array.from(e.clipboardData?.items || [])
const imageItems = items.filter(i => i.type.startsWith('image/'))
if (!imageItems.length) return
e.preventDefault()
for (const item of imageItems) {
const blob = item.getAsFile()
if (!blob) continue
const ext = item.type.split('/')[1] || 'png'
addFile(new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type }))
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
}
function handleDragEnter(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer?.types.includes('Files')) {
dragCounter.value++
isDragging.value = true
}
}
function handleDragLeave() {
dragCounter.value--
if (dragCounter.value <= 0) {
dragCounter.value = 0
isDragging.value = false
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
dragCounter.value = 0
isDragging.value = false
for (const file of Array.from(e.dataTransfer?.files || [])) addFile(file)
textareaRef.value?.focus()
}
function removeAttachment(id: string) {
const idx = attachments.value.findIndex(a => a.id === id)
if (idx !== -1) {
URL.revokeObjectURL(attachments.value[idx].url)
attachments.value.splice(idx, 1)
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function isImage(type: string): boolean {
return type.startsWith('image/')
}
</script>
<template>
<div class="chat-input-area">
<div class="input-wrapper">
<div class="input-top-bar">
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" circle @click="handleAttachClick">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</template>
</NButton>
</template>
{{ t('chat.attachFiles') }}
</NTooltip>
<div class="auto-play-speech-switch">
<NTooltip trigger="hover">
<template #trigger>
<div class="switch-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
</template>
{{ t('chat.autoPlaySpeech') }}
</NTooltip>
<NSwitch v-model:value="autoPlaySpeech" size="small" :round="false" />
</div>
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" class="tool-trace-toggle" :class="{ active: toolTraceVisible }" @click="toggleToolTraceVisible">
<svg class="tool-trace-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a4.5 4.5 0 0 0-5.8 5.8L3.5 17.5a2.1 2.1 0 0 0 3 3l5.4-5.4a4.5 4.5 0 0 0 5.8-5.8l-3 3-3-3 3-3z"/>
</svg>
</NButton>
</template>
{{ toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls') }}
</NTooltip>
</div>
<div v-if="attachments.length > 0" class="attachment-previews">
<div v-for="att in attachments" :key="att.id" class="attachment-preview" :class="{ image: isImage(att.type) }">
<img v-if="isImage(att.type)" :src="att.url" :alt="att.name" class="attachment-thumb" />
<div v-else class="attachment-file">
<span class="file-name">{{ att.name }}</span>
<span class="file-size">{{ formatSize(att.size) }}</span>
</div>
<button class="attachment-remove" @click="removeAttachment(att.id)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<div
class="input-wrapper"
:class="{ 'drag-over': isDragging }"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<input ref="fileInputRef" type="file" multiple class="file-input-hidden" @change="handleFileChange" />
<div class="resize-handle" @mousedown="startResize"></div>
<textarea
ref="textareaRef"
@@ -273,6 +431,7 @@ function handleCompositionEnd() {
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@input="handleInput"
@paste="handlePaste"
/>
<div class="input-actions">
<NButton
@@ -320,11 +479,138 @@ function handleCompositionEnd() {
@use "@/styles/variables" as *;
.chat-input-area {
padding: 20px 20px 16px;
padding: 12px 20px 16px;
border-top: 1px solid $border-color;
flex-shrink: 0;
}
.input-top-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 0 6px;
}
.auto-play-speech-switch {
display: flex;
align-items: center;
gap: 6px;
padding-left: 8px;
border-left: 1px solid $border-light;
margin-left: 4px;
.switch-label {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #999999;
}
}
.tool-trace-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
color: #999999;
width: 24px;
min-width: 24px;
height: 22px;
margin-left: -4px;
padding: 0;
background: transparent !important;
:deep(.n-button__state-border),
:deep(.n-button__border),
:deep(.n-button__ripple) {
display: none;
}
.tool-trace-icon {
display: block;
width: 16px;
height: 16px;
}
}
.attachment-previews {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 10px;
}
.attachment-preview {
position: relative;
border-radius: $radius-sm;
overflow: hidden;
background-color: $bg-secondary;
border: 1px solid $border-color;
&.image {
width: 64px;
height: 64px;
}
}
.attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachment-file {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 12px;
min-width: 80px;
max-width: 140px;
color: $text-secondary;
.file-name {
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.file-size {
font-size: 10px;
color: $text-muted;
}
}
.attachment-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.5);
color: var(--text-on-overlay);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity $transition-fast;
.attachment-preview:hover & {
opacity: 1;
}
}
.file-input-hidden {
display: none;
}
.typing-dots {
display: inline-flex;
align-items: center;
@@ -363,6 +649,11 @@ function handleCompositionEnd() {
border-color: $accent-primary;
}
&.drag-over {
border-color: $accent-primary;
background-color: rgba($accent-primary, 0.08);
}
.dark & {
background-color: #333333;
}