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
parent 3d74d78698
commit 9a9416c99c
129 changed files with 7017 additions and 1838 deletions
+344 -6
View File
@@ -1,5 +1,8 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getApiKey } from '@/api/client'
import { getDownloadUrl } from '@/api/hermes/download'
import type { Attachment, ContentBlock } from './chat'
import {
connectGroupChat,
disconnectGroupChat,
@@ -22,6 +25,66 @@ import {
clearRoomContext,
} from '@/api/hermes/group-chat'
async function uploadGroupFiles(attachments: Attachment[]): Promise<{ name: string; path: string }[]> {
const formData = new FormData()
for (const att of attachments) {
if (att.file) formData.append('file', att.file, att.name)
}
const token = getApiKey()
const res = await fetch('/upload', {
method: 'POST',
body: formData,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json() as { files: { name: string; path: string }[] }
return data.files
}
function buildGroupContentBlocks(content: string, attachments: Attachment[], files: { name: string; path: string }[]): ContentBlock[] {
const blocks: ContentBlock[] = []
if (content.trim()) blocks.push({ type: 'text', text: content.trim() })
for (let i = 0; i < files.length; i += 1) {
const file = files[i]
const attachment = attachments[i]
if (attachment?.type.startsWith('image/')) {
blocks.push({
type: 'image',
name: file.name,
path: file.path,
media_type: attachment.type,
})
} else {
blocks.push({
type: 'file',
name: file.name,
path: file.path,
media_type: attachment?.type,
})
}
}
return blocks
}
function uid(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
function normalizeLocalFilePath(path: string): string {
return /^[a-zA-Z]:\\/.test(path) ? path.replace(/\\/g, '/') : path
}
export interface GroupPendingApproval {
roomId: string
agentName: string
approvalId: string
command: string
description: string
choices: Array<'once' | 'session' | 'always' | 'deny'>
allowPermanent: boolean
requestedAt: number
}
export const useGroupChatStore = defineStore('groupChat', () => {
// ─── State ─────────────────────────────────────────────
const connected = ref(false)
@@ -35,6 +98,18 @@ export const useGroupChatStore = defineStore('groupChat', () => {
const error = ref<string | null>(null)
const typingUsers = ref<Map<string, { name: string; timer: ReturnType<typeof setTimeout> }>>(new Map())
const contextStatuses = ref<Map<string, { agentName: string; status: string }>>(new Map())
const autoPlaySpeechEnabled = ref(false)
const pendingApprovals = ref<Map<string, GroupPendingApproval>>(new Map())
function setAutoPlaySpeech(enabled: boolean) {
autoPlaySpeechEnabled.value = enabled
}
function playMessageSpeech(messageId: string, content: string) {
window.dispatchEvent(new CustomEvent('auto-play-speech', {
detail: { messageId, content },
}))
}
// Computed: returns first active status for backward compat
const contextStatus = computed(() => {
@@ -43,13 +118,18 @@ export const useGroupChatStore = defineStore('groupChat', () => {
}
return null
})
const activePendingApproval = computed(() => {
if (!currentRoomId.value) return null
for (const approval of pendingApprovals.value.values()) {
if (approval.roomId === currentRoomId.value) return approval
}
return null
})
const userId = ref(getStoredUserId())
const userName = ref(getStoredUserName() || '')
// ─── Computed ───────────────────────────────────────────
const sortedMessages = computed(() => {
return [...messages.value].sort((a, b) => a.timestamp - b.timestamp)
})
const sortedMessages = computed(() => mapGroupMessages([...messages.value].sort((a, b) => a.timestamp - b.timestamp)))
const memberNames = computed(() => {
return members.value.map(m => m.name)
@@ -94,10 +174,89 @@ export const useGroupChatStore = defineStore('groupChat', () => {
socket.on('message', (msg: ChatMessage) => {
if (msg.roomId === currentRoomId.value) {
const idx = messages.value.findIndex(m => m.id === msg.id)
const existing = idx >= 0 ? messages.value[idx] : null
const resolvedMsg = {
...msg,
isStreaming: false,
attachments: existing?.attachments,
}
if (idx >= 0) {
messages.value[idx] = resolvedMsg
messages.value = [...messages.value]
} else {
messages.value.push(resolvedMsg)
}
if (autoPlaySpeechEnabled.value && resolvedMsg.role === 'assistant' && resolvedMsg.content?.trim()) {
setTimeout(() => playMessageSpeech(resolvedMsg.id, resolvedMsg.content), 300)
}
}
})
socket.on('message_stream_start', (msg: ChatMessage) => {
if (msg.roomId !== currentRoomId.value) return
messages.value = messages.value.filter(m => !(
m.roomId === msg.roomId &&
m.senderId === msg.senderId &&
m.id !== msg.id &&
m.isStreaming &&
!m.content?.trim() &&
!m.reasoning?.trim() &&
!m.tool_calls?.length
))
msg.isStreaming = true
const idx = messages.value.findIndex(m => m.id === msg.id)
if (idx >= 0) {
messages.value[idx] = { ...messages.value[idx], ...msg, isStreaming: true }
messages.value = [...messages.value]
} else {
messages.value.push(msg)
}
})
socket.on('message_stream_delta', (data: { roomId: string; id: string; delta: string }) => {
if (data.roomId !== currentRoomId.value) return
const idx = messages.value.findIndex(m => m.id === data.id)
if (idx < 0) return
messages.value[idx] = {
...messages.value[idx],
content: messages.value[idx].content + data.delta,
}
messages.value = [...messages.value]
})
socket.on('message_reasoning_delta', (data: { roomId: string; id: string; delta: string }) => {
if (data.roomId !== currentRoomId.value) return
const idx = messages.value.findIndex(m => m.id === data.id)
if (idx < 0) return
messages.value[idx] = {
...messages.value[idx],
reasoning: (messages.value[idx].reasoning || '') + data.delta,
reasoning_content: (messages.value[idx].reasoning_content || '') + data.delta,
isStreaming: true,
}
messages.value = [...messages.value]
})
socket.on('message_stream_end', (data: { roomId: string; id: string }) => {
if (data.roomId !== currentRoomId.value) return
const idx = messages.value.findIndex(m => m.id === data.id)
if (
idx >= 0 &&
!messages.value[idx].content?.trim() &&
!messages.value[idx].reasoning?.trim() &&
!messages.value[idx].tool_calls?.length
) {
messages.value.splice(idx, 1)
} else if (idx >= 0) {
messages.value[idx] = {
...messages.value[idx],
isStreaming: false,
}
messages.value = [...messages.value]
}
})
socket.on('member_joined', (data: { roomId: string; members: MemberInfo[] }) => {
if (data.roomId === currentRoomId.value) {
members.value = data.members
@@ -129,6 +288,18 @@ export const useGroupChatStore = defineStore('groupChat', () => {
if (data.roomId === currentRoomId.value) {
if (data.status === 'ready') {
contextStatuses.value.delete(data.agentName)
messages.value = messages.value
.map(m => (
m.senderName === data.agentName && m.isStreaming
? { ...m, isStreaming: false }
: m
))
.filter(m => !(
m.senderName === data.agentName &&
!m.content?.trim() &&
!m.reasoning?.trim() &&
!m.tool_calls?.length
))
} else {
contextStatuses.value.set(data.agentName, { agentName: data.agentName, status: data.status })
}
@@ -137,6 +308,30 @@ export const useGroupChatStore = defineStore('groupChat', () => {
}
})
socket.on('approval.requested', (data: { roomId: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }) => {
if (!data.approval_id) return
const choices = (Array.isArray(data.choices) ? data.choices : ['once', 'session', 'deny'])
.filter((choice): choice is GroupPendingApproval['choices'][number] =>
choice === 'once' || choice === 'session' || choice === 'always' || choice === 'deny')
pendingApprovals.value.set(data.approval_id, {
roomId: data.roomId,
agentName: data.agentName || '',
approvalId: data.approval_id,
command: data.command || '',
description: data.description || '',
choices: choices.length ? choices : ['once', 'session', 'deny'],
allowPermanent: Boolean(data.allow_permanent),
requestedAt: Date.now(),
})
pendingApprovals.value = new Map(pendingApprovals.value)
})
socket.on('approval.resolved', (data: { approval_id?: string }) => {
if (!data.approval_id) return
pendingApprovals.value.delete(data.approval_id)
pendingApprovals.value = new Map(pendingApprovals.value)
})
socket.on('room_updated', (data: { roomId: string; totalTokens: number }) => {
const room = rooms.value.find(r => r.id === data.roomId)
if (room) room.totalTokens = data.totalTokens
@@ -149,6 +344,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
messages.value = []
typingUsers.value.clear()
contextStatuses.value.clear()
pendingApprovals.value.clear()
}
})
}
@@ -163,6 +359,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
roomName.value = ''
typingUsers.value.clear()
contextStatuses.value.clear()
pendingApprovals.value.clear()
}
function setUserInfo(name: string, description: string) {
@@ -194,7 +391,11 @@ export const useGroupChatStore = defineStore('groupChat', () => {
const socket = getSocket()
if (socket) {
await new Promise<void>((resolve) => {
socket.emit('join', { roomId, name: userName.value || undefined }, (res: any) => {
socket.emit('join', {
roomId,
name: userName.value || undefined,
description: localStorage.getItem('gc_user_description') || undefined,
}, (res: any) => {
if (!res?.error) {
members.value = res.members || []
if (res.agents) agents.value = res.agents
@@ -222,14 +423,34 @@ export const useGroupChatStore = defineStore('groupChat', () => {
}
}
async function sendMessage(content: string) {
async function sendMessage(content: string, attachments?: Attachment[]) {
const socket = getSocket()
if (!socket || !currentRoomId.value) return
emitStopTyping()
const messageId = uid()
let finalContent: string | ContentBlock[] = content.trim()
if (attachments?.length) {
const uploaded = await uploadGroupFiles(attachments)
finalContent = buildGroupContentBlocks(content, attachments, uploaded)
const urlMap = new Map(uploaded.map(f => {
return [f.name, getDownloadUrl(normalizeLocalFilePath(f.path), f.name)]
}))
messages.value.push({
id: messageId,
roomId: currentRoomId.value,
senderId: userId.value,
senderName: userName.value || 'You',
content: JSON.stringify(finalContent),
timestamp: Date.now(),
role: 'user',
attachments: attachments.map(att => ({ ...att, url: urlMap.get(att.name) || att.url, file: undefined })),
})
}
return new Promise<void>((resolve, reject) => {
socket!.emit('message', { roomId: currentRoomId.value, content }, (res: { id?: string; error?: string }) => {
socket!.emit('message', { roomId: currentRoomId.value, id: messageId, content: finalContent }, (res: { id?: string; error?: string }) => {
if (res.error) {
messages.value = messages.value.filter(m => m.id !== messageId)
reject(new Error(res.error))
return
}
@@ -365,6 +586,35 @@ export const useGroupChatStore = defineStore('groupChat', () => {
if (_typingTimer) { clearTimeout(_typingTimer); _typingTimer = null }
}
async function interruptAgent(agentName: string) {
const socket = getSocket()
if (!socket || !currentRoomId.value) return
await new Promise<void>((resolve, reject) => {
socket.emit('interrupt_agent', { roomId: currentRoomId.value, agentName }, (res: any) => {
if (res?.error) reject(new Error(res.error))
else resolve()
})
})
}
async function respondApproval(choice: GroupPendingApproval['choices'][number]) {
const socket = getSocket()
const pending = activePendingApproval.value
if (!socket || !pending) return
await new Promise<void>((resolve, reject) => {
socket.emit('approval.respond', {
roomId: pending.roomId,
approval_id: pending.approvalId,
choice,
}, (res: any) => {
if (res?.error) reject(new Error(res.error))
else resolve()
})
})
pendingApprovals.value.delete(pending.approvalId)
pendingApprovals.value = new Map(pendingApprovals.value)
}
return {
// State
connected,
@@ -378,6 +628,9 @@ export const useGroupChatStore = defineStore('groupChat', () => {
error,
contextStatus,
contextStatuses,
pendingApprovals,
activePendingApproval,
autoPlaySpeechEnabled,
userId,
userName,
// Computed
@@ -389,11 +642,14 @@ export const useGroupChatStore = defineStore('groupChat', () => {
connect,
disconnect,
setUserInfo,
setAutoPlaySpeech,
joinRoom,
sendMessage,
loadRooms,
emitTyping,
emitStopTyping,
interruptAgent,
respondApproval,
createNewRoom,
joinByCode,
deleteRoom,
@@ -404,3 +660,85 @@ export const useGroupChatStore = defineStore('groupChat', () => {
removeAgentFromRoom,
}
})
function mapGroupMessages(msgs: ChatMessage[]): ChatMessage[] {
const toolNameMap = new Map<string, string>()
const toolArgsMap = new Map<string, string>()
for (const msg of msgs) {
if (msg.role === 'assistant' && msg.tool_calls?.length) {
for (const tc of msg.tool_calls) {
if (!tc?.id) continue
if (tc.function?.name) toolNameMap.set(tc.id, tc.function.name)
if (tc.function?.arguments) toolArgsMap.set(tc.id, tc.function.arguments)
}
}
}
const result: ChatMessage[] = []
for (const msg of msgs) {
if (
msg.role !== 'tool' &&
!msg.tool_calls?.length &&
!msg.content?.trim() &&
!msg.reasoning?.trim() &&
(!msg.isStreaming || msg.finish_reason === 'streaming')
) {
continue
}
if (msg.role === 'assistant' && msg.tool_calls?.length && !msg.content?.trim()) {
for (const tc of msg.tool_calls) {
result.push({
...msg,
id: `${msg.id}_${tc.id}`,
role: 'tool',
content: '',
toolName: tc.function?.name || undefined,
toolCallId: tc.id,
toolArgs: tc.function?.arguments || undefined,
toolStatus: 'running',
})
}
continue
}
if (msg.role === 'tool') {
const tcId = msg.tool_call_id || ''
const toolName = msg.tool_name || toolNameMap.get(tcId) || undefined
const toolArgs = toolArgsMap.get(tcId) || undefined
let preview = ''
if (msg.content) {
try {
const parsed = JSON.parse(msg.content)
preview = parsed.url || parsed.title || parsed.preview || parsed.summary || ''
} catch {
preview = msg.content.slice(0, 80)
}
}
const placeholderIdx = result.findIndex(
m => m.role === 'tool' && m.toolCallId === tcId && !m.toolResult
)
const merged: ChatMessage = {
...msg,
id: placeholderIdx !== -1 ? result[placeholderIdx].id : msg.id,
senderId: placeholderIdx !== -1 ? result[placeholderIdx].senderId : msg.senderId,
senderName: placeholderIdx !== -1 ? result[placeholderIdx].senderName : msg.senderName,
timestamp: placeholderIdx !== -1 ? result[placeholderIdx].timestamp : msg.timestamp,
role: 'tool',
content: '',
toolName: toolName || (placeholderIdx !== -1 ? result[placeholderIdx].toolName : undefined),
toolCallId: tcId || undefined,
toolArgs: toolArgs || (placeholderIdx !== -1 ? result[placeholderIdx].toolArgs : undefined),
toolPreview: typeof preview === 'string' ? preview.slice(0, 100) || undefined : undefined,
toolResult: msg.content || undefined,
toolStatus: 'done',
}
if (placeholderIdx !== -1) result[placeholderIdx] = merged
else result.push(merged)
continue
}
result.push(msg)
}
return result
}