Update CLI chat session bridge (#697)

* feat: add CLI chat sessions with Python agent bridge

Introduce a new CLI chat mode that connects Web UI directly to Hermes
Agent's AIAgent via a Python bridge subprocess and Socket.IO, bypassing
the API Server /v1/responses path. Supports streaming, slash commands
(/new, /undo, /retry, /branch, /compress, /save, /title), interrupt,
and steer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: update CLI chat session bridge

* fix: extend agent bridge startup timeouts

* docs: update bridge chat session design

* feat: align bridge compression and provider registry

* chore: bump version to 0.5.20

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-14 09:03:57 +08:00
committed by GitHub
parent e0fcc0040b
commit eae7195ba8
31 changed files with 3906 additions and 1040 deletions
+147 -1
View File
@@ -1,4 +1,4 @@
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { getApiKey } from '@/api/client'
import { defineStore } from 'pinia'
@@ -43,6 +43,16 @@ export interface Message {
queued?: boolean
}
export interface PendingApproval {
sessionId: string
approvalId: string
command: string
description: string
choices: Array<'once' | 'session' | 'always' | 'deny'>
allowPermanent: boolean
requestedAt: number
}
export interface Session {
id: string
title: string
@@ -320,6 +330,11 @@ export const useChatStore = defineStore('chat', () => {
const queueLengths = ref<Map<string, number>>(new Map())
/** sessionId → queued user messages not yet visible in the transcript */
const queuedUserMessages = ref<Map<string, Message[]>>(new Map())
const pendingApprovals = ref<Map<string, PendingApproval>>(new Map())
const activePendingApproval = computed(() => {
const sid = activeSessionId.value
return sid ? pendingApprovals.value.get(sid) || null : null
})
// 自动播放语音开关
const autoPlaySpeechEnabled = ref(false)
@@ -432,6 +447,30 @@ export const useChatStore = defineStore('chat', () => {
return session
}
function newCliSession(): Session {
const now = new Date()
const ts = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
'_',
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
].join('')
const hex = Math.random().toString(16).slice(2, 8)
const session: Session = {
id: `${ts}_${hex}`,
title: '',
source: 'cli',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
sessions.value.unshift(session)
return session
}
async function switchSession(sessionId: string, focusId?: string | null) {
clearThinkingObservationFor(sessionId)
activeSessionId.value = sessionId
@@ -503,6 +542,49 @@ export const useChatStore = defineStore('chat', () => {
setAbortState({ aborting: true, synced: null })
} else if (e.event === 'abort.completed') {
setAbortState({ aborting: false, synced: e.synced ?? false })
} else if (e.event === 'approval.requested') {
setPendingApproval({ ...e, session_id: sessionId } as RunEvent)
} else if (e.event === 'approval.resolved') {
clearPendingApproval({ ...e, session_id: sessionId } as RunEvent)
} else if (e.event === 'tool.started') {
const msgs = getSessionMsgs(sessionId)
const toolCallId = e.tool_call_id as string | undefined
const existingTool = toolCallId
? msgs.find(m => m.role === 'tool' && m.toolCallId === toolCallId)
: null
if (existingTool) {
updateMessage(sessionId, existingTool.id, {
toolName: e.tool || e.name,
toolArgs: typeof e.arguments === 'string' ? e.arguments : existingTool.toolArgs,
toolPreview: e.preview || existingTool.toolPreview,
toolStatus: existingTool.toolStatus || 'running',
})
} else {
addMessage(sessionId, {
id: uid(),
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: e.tool || e.name,
toolCallId,
toolPreview: e.preview,
toolArgs: typeof e.arguments === 'string' ? e.arguments : undefined,
toolStatus: 'running',
})
}
} else if (e.event === 'tool.completed') {
const msgs = getSessionMsgs(sessionId)
const toolCallId = e.tool_call_id as string | undefined
const toolMsgs = toolCallId
? msgs.filter(m => m.role === 'tool' && m.toolCallId === toolCallId)
: msgs.filter(m => m.role === 'tool' && m.toolStatus === 'running')
if (toolMsgs.length > 0) {
updateMessage(sessionId, toolMsgs[toolMsgs.length - 1].id, {
toolStatus: e.error === true ? 'error' : 'done',
toolDuration: e.duration,
toolResult: typeof e.output === 'string' ? e.output : undefined,
})
}
}
}
}
@@ -603,6 +685,45 @@ export const useChatStore = defineStore('chat', () => {
})
}
function setPendingApproval(evt: RunEvent) {
const sid = evt.session_id
const approvalId = (evt as any).approval_id as string | undefined
if (!sid || !approvalId) return
const rawChoices = Array.isArray((evt as any).choices) ? (evt as any).choices : ['once', 'session', 'deny']
const choices = rawChoices
.filter((choice: unknown): choice is PendingApproval['choices'][number] =>
choice === 'once' || choice === 'session' || choice === 'always' || choice === 'deny')
pendingApprovals.value.set(sid, {
sessionId: sid,
approvalId,
command: String((evt as any).command || ''),
description: String((evt as any).description || ''),
choices: choices.length ? choices : ['once', 'session', 'deny'],
allowPermanent: Boolean((evt as any).allow_permanent),
requestedAt: Date.now(),
})
pendingApprovals.value = new Map(pendingApprovals.value)
}
function clearPendingApproval(evt: RunEvent) {
const sid = evt.session_id
if (!sid) return
const current = pendingApprovals.value.get(sid)
if (!current) return
const approvalId = (evt as any).approval_id
if (approvalId && current.approvalId !== approvalId) return
pendingApprovals.value.delete(sid)
pendingApprovals.value = new Map(pendingApprovals.value)
}
function respondApproval(choice: PendingApproval['choices'][number]) {
const pending = activePendingApproval.value
if (!pending) return
respondToolApproval(pending.sessionId, pending.approvalId, choice)
pendingApprovals.value.delete(pending.sessionId)
pendingApprovals.value = new Map(pendingApprovals.value)
}
function showNextQueuedUserMessage(sessionId: string) {
const queue = queuedUserMessages.value.get(sessionId)
if (!queue?.length) return
@@ -715,6 +836,7 @@ export const useChatStore = defineStore('chat', () => {
session_id: sid,
model: sessionModel || undefined,
queue_id: userMsg.id,
source: (activeSession.value?.source === 'cli' ? 'cli' : 'api_server') as 'cli' | 'api_server',
}
if (shouldQueue) {
@@ -967,6 +1089,16 @@ export const useChatStore = defineStore('chat', () => {
break
}
case 'approval.requested': {
setPendingApproval(evt)
break
}
case 'approval.resolved': {
clearPendingApproval(evt)
break
}
case 'run.completed': {
const msgs = getSessionMsgs(sid)
const lastMsg = activeAssistantMessageId
@@ -1394,6 +1526,16 @@ export const useChatStore = defineStore('chat', () => {
break
}
case 'approval.requested': {
setPendingApproval(evt)
break
}
case 'approval.resolved': {
clearPendingApproval(evt)
break
}
case 'run.completed': {
const hasQueue = (evt as any).queue_remaining > 0
if (hasQueue) {
@@ -1689,12 +1831,15 @@ export const useChatStore = defineStore('chat', () => {
isAborting,
queueLengths,
queuedUserMessages,
pendingApprovals,
activePendingApproval,
removeQueuedMessage,
isLoadingSessions,
sessionsLoaded,
isLoadingMessages,
newChat,
newCliSession,
switchSession,
switchSessionModel,
addOrUpdateSession,
@@ -1702,6 +1847,7 @@ export const useChatStore = defineStore('chat', () => {
deleteSession,
sendMessage,
stopStreaming,
respondApproval,
loadSessions,
refreshActiveSession,
getThinkingObservation,