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
@@ -124,11 +124,16 @@ const groupedSessions = computed<SessionGroup[]>(() => {
return keys.map((key) => ({
source: key,
label: key ? getSourceLabel(key) : t("chat.other"),
label: key ? getChatSourceLabel(key) : t("chat.other"),
sessions: sortSessionsWithActiveFirst(map.get(key)!),
}));
});
function getChatSourceLabel(source?: string): string {
if (source === "cli") return "Bridge (beta)";
return getSourceLabel(source);
}
function toggleGroup(source: string) {
const isExpanded = !collapsedGroups.value.has(source);
if (isExpanded) {
@@ -204,10 +209,40 @@ const activeSessionSource = computed(() =>
currentMode.value === "chat" ? chatStore.activeSession?.source || "" : "",
);
const activeApproval = computed(() => chatStore.activePendingApproval);
function handleNewChat() {
chatStore.newChat();
}
function handleNewCliChat() {
const session = chatStore.newCliSession()
chatStore.switchSession(session.id)
}
const newChatOptions = computed(() => [
{
label: "API",
key: "api_server",
},
{
label: "Bridge (beta)",
key: "cli",
},
]);
function handleNewChatSelect(key: string | number) {
if (key === "cli") {
handleNewCliChat();
return;
}
handleNewChat();
}
function handleApproval(choice: "once" | "session" | "always" | "deny") {
chatStore.respondApproval(choice);
}
async function copySessionId(id?: string) {
const sessionId = id || chatStore.activeSessionId;
if (sessionId) {
@@ -556,21 +591,27 @@ async function handleWorkspaceConfirm() {
</svg>
</template>
</NButton>
<NButton quaternary size="tiny" @click="handleNewChat" circle>
<template #icon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</template>
</NButton>
<NDropdown
trigger="click"
:options="newChatOptions"
@select="handleNewChatSelect"
>
<NButton quaternary size="tiny" circle>
<template #icon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</template>
</NButton>
</NDropdown>
</div>
</div>
<div v-if="showSessions" class="session-scope-note">
@@ -723,7 +764,7 @@ async function handleWorkspaceConfirm() {
</NButton>
<span class="header-session-title">{{ headerTitle }}</span>
<span v-if="activeSessionSource" class="source-badge">{{
getSourceLabel(activeSessionSource)
getChatSourceLabel(activeSessionSource)
}}</span>
<span
v-if="chatStore.activeSession?.workspace"
@@ -766,28 +807,74 @@ async function handleWorkspaceConfirm() {
</template>
{{ t("chat.copySessionId") }}
</NTooltip>
<NButton size="small" :circle="isMobile" @click="handleNewChat">
<template #icon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</template>
<template v-if="!isMobile">{{ t("chat.newChat") }}</template>
</NButton>
<NDropdown
trigger="click"
:options="newChatOptions"
@select="handleNewChatSelect"
>
<NButton size="small" :circle="isMobile">
<template #icon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</template>
<template v-if="!isMobile">{{ t("chat.newChat") }}</template>
</NButton>
</NDropdown>
</template>
</div>
</header>
<template v-if="currentMode === 'chat'">
<MessageList />
<div v-if="activeApproval" class="approval-bar">
<div class="approval-main">
<div class="approval-title">Tool approval required</div>
<div class="approval-desc">{{ activeApproval.description }}</div>
<code class="approval-command">{{ activeApproval.command }}</code>
</div>
<div class="approval-actions">
<NButton
v-if="activeApproval.choices.includes('once')"
size="small"
type="primary"
@click="handleApproval('once')"
>
Allow once
</NButton>
<NButton
v-if="activeApproval.choices.includes('session')"
size="small"
@click="handleApproval('session')"
>
Allow session
</NButton>
<NButton
v-if="activeApproval.choices.includes('always')"
size="small"
@click="handleApproval('always')"
>
Always
</NButton>
<NButton
v-if="activeApproval.choices.includes('deny')"
size="small"
type="error"
ghost
@click="handleApproval('deny')"
>
Deny
</NButton>
</div>
</div>
<ChatInput />
</template>
<ConversationMonitorPane
@@ -1259,6 +1346,54 @@ async function handleWorkspaceConfirm() {
}
}
.approval-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-top: 1px solid $border-color;
background: $bg-card;
}
.approval-main {
flex: 1;
min-width: 0;
}
.approval-title {
font-size: 13px;
font-weight: 600;
color: $text-primary;
}
.approval-desc {
margin-top: 2px;
font-size: 12px;
color: $text-secondary;
}
.approval-command {
display: block;
margin-top: 6px;
max-height: 56px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
color: $text-primary;
background: $bg-secondary;
border: 1px solid $border-color;
border-radius: 6px;
padding: 6px 8px;
}
.approval-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
@keyframes rainbow-glow {
0% {
box-shadow: