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
+51 -1
View File
@@ -17,6 +17,7 @@ export interface StartRunRequest {
session_id?: string
model?: string
queue_id?: string
source?: 'api_server' | 'cli'
}
export interface StartRunResponse {
@@ -77,6 +78,8 @@ const sessionEventHandlers = new Map<string, {
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
}>()
/**
@@ -288,6 +291,26 @@ function globalUsageUpdatedHandler(event: RunEvent): void {
}
}
function globalApprovalRequestedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onApprovalRequested) {
handlers.onApprovalRequested(event)
}
}
function globalApprovalResolvedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onApprovalResolved) {
handlers.onApprovalResolved(event)
}
}
/**
* Register event handlers for a session
* @param sessionId - Session ID
@@ -312,6 +335,8 @@ export function registerSessionHandlers(
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
}
): () => void {
sessionEventHandlers.set(sessionId, handlers)
@@ -330,6 +355,19 @@ export function unregisterSessionHandlers(sessionId: string): void {
sessionEventHandlers.delete(sessionId)
}
export function respondToolApproval(
sessionId: string,
approvalId: string,
choice: 'once' | 'session' | 'always' | 'deny',
): void {
const socket = connectChatRun()
socket.emit('approval.respond', {
session_id: sessionId,
approval_id: approvalId,
choice,
})
}
export function getChatRunSocket(): Socket | null {
return chatRunSocket
}
@@ -365,7 +403,9 @@ export function connectChatRun(): Socket {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
reconnectionDelayMax: 30000,
randomizationFactor: 0.5,
timeout: 30000,
})
// Register global listeners only once per socket connection
@@ -385,6 +425,8 @@ export function connectChatRun(): Socket {
chatRunSocket.on('run.failed', globalRunFailedHandler)
chatRunSocket.on('run.completed', globalRunCompletedHandler)
chatRunSocket.on('run.queued', globalRunQueuedHandler)
chatRunSocket.on('approval.requested', globalApprovalRequestedHandler)
chatRunSocket.on('approval.resolved', globalApprovalResolvedHandler)
// Compression events
chatRunSocket.on('compression.started', globalCompressionStartedHandler)
@@ -527,6 +569,14 @@ export function startRunViaSocket(
if (closed) return
onEvent(evt)
},
onApprovalRequested: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onApprovalResolved: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
}
// Register handlers in the global session map
+3 -2
View File
@@ -66,11 +66,13 @@ export function connectGroupChat(opts?: { userId?: string; userName?: string; de
name: opts?.userName || localStorage.getItem('gc_user_name') || undefined,
description: opts?.description || localStorage.getItem('gc_user_description') || undefined,
},
transports: ['websocket'],
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
randomizationFactor: 0.5,
timeout: 30000,
})
return socket
@@ -185,4 +187,3 @@ export async function forceCompress(roomId: string): Promise<{ success: boolean;
method: 'POST',
})
}
@@ -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:
@@ -26,10 +26,6 @@ function formatToolDuration(seconds: number): string {
return `${mins}m ${secs}s`
}
const displayMessages = computed(() =>
chatStore.messages.filter((m) => m.role !== "tool"),
);
const currentToolCalls = computed(() => {
const msgs = chatStore.messages;
// Find the last user message index
@@ -45,6 +41,22 @@ const currentToolCalls = computed(() => {
return [...tools].reverse();
});
const displayMessages = computed(() =>
chatStore.messages.filter((m) => {
if (m.role === "tool") return false;
if (
m.role === "assistant" &&
m.isStreaming &&
!m.content?.trim() &&
!!m.reasoning?.trim() &&
currentToolCalls.value.length === 0
) {
return false;
}
return true;
}),
);
const queuedMessages = computed(() => {
const sid = chatStore.activeSessionId;
if (!sid) return [];
+2
View File
@@ -131,6 +131,7 @@ export default {
contextEditSuccess: 'Context length updated',
contextEditFailed: 'Update failed',
emptyState: 'Start a conversation with Hermes Agent',
cliEmptyState: 'Start a CLI chat session',
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
attachFiles: 'Attach files',
autoPlaySpeech: 'Auto-play voice',
@@ -159,6 +160,7 @@ export default {
searchEnterHint: 'Enter to open · Esc to close',
searchFailed: 'Failed to search sessions',
newChat: 'New Chat',
newCliChat: 'New CLI',
deleteSession: 'Delete this session?',
sessionDeleted: 'Session deleted',
toggleBatchMode: 'Batch selection',
+2
View File
@@ -131,6 +131,7 @@ export default {
contextEditSuccess: '上下文长度已更新',
contextEditFailed: '更新失败',
emptyState: '开始与 Hermes Agent 对话',
cliEmptyState: '开始 CLI 对话',
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
attachFiles: '添加附件',
autoPlaySpeech: '自动播放语音',
@@ -159,6 +160,7 @@ export default {
searchEnterHint: 'Enter 打开 · Esc 关闭',
searchFailed: '搜索会话失败',
newChat: '新建对话',
newCliChat: '新建 CLI',
deleteSession: '确定删除此会话?',
sessionDeleted: '会话已删除',
toggleBatchMode: '批量选择',
-310
View File
@@ -1,310 +0,0 @@
/**
* Provider registry — single source of truth for both frontend and backend.
* Synced from hermes-agent hermes_cli/models.py _PROVIDER_MODELS.
*/
export interface ProviderPreset {
label: string
value: string
base_url: string
models: string[]
}
export const PROVIDER_PRESETS: ProviderPreset[] = [
{
label: 'Anthropic',
value: 'anthropic',
base_url: 'https://api.anthropic.com',
models: [
'claude-opus-4-7',
'claude-opus-4-6',
'claude-sonnet-4-6',
'claude-opus-4-5-20251101',
'claude-sonnet-4-5-20250929',
'claude-opus-4-20250514',
'claude-sonnet-4-20250514',
'claude-haiku-4-5-20251001',
],
},
{
label: 'Google AI Studio',
value: 'gemini',
base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
models: [
'gemini-3.1-pro-preview',
'gemini-3-flash-preview',
'gemini-3.1-flash-lite-preview',
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemma-4-31b-it',
'gemma-4-26b-it',
],
},
{
label: 'DeepSeek',
value: 'deepseek',
base_url: 'https://api.deepseek.com',
models: ['deepseek-chat', 'deepseek-reasoner'],
},
{
label: 'Z.AI / GLM',
value: 'zai',
base_url: 'https://api.z.ai/api/paas/v4',
models: ['glm-5.1', 'glm-5', 'glm-5v-turbo', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'],
},
{
label: 'Kimi for Coding',
value: 'kimi-coding',
base_url: 'https://api.kimi.com/coding/v1',
models: [
'kimi-for-coding',
'kimi-k2.5',
'kimi-k2-thinking',
'kimi-k2-thinking-turbo',
'kimi-k2-turbo-preview',
'kimi-k2-0905-preview',
],
},
{
label: 'Kimi for Coding (CN)',
value: 'kimi-coding-cn',
base_url: 'https://api.kimi.com/coding/v1',
models: [
'kimi-k2.5',
'kimi-k2-thinking',
'kimi-k2-turbo-preview',
'kimi-k2-0905-preview',
],
},
{
label: 'Moonshot',
value: 'moonshot',
base_url: 'https://api.moonshot.cn/v1',
models: [
'kimi-k2.5',
'kimi-k2-thinking',
'kimi-k2-turbo-preview',
'kimi-k2-0905-preview',
],
},
{
label: 'xAI',
value: 'xai',
base_url: 'https://api.x.ai/v1',
models: ['grok-4.20-reasoning', 'grok-4-1-fast-reasoning'],
},
{
label: 'MiniMax',
value: 'minimax',
base_url: 'https://api.minimax.io/anthropic/v1',
models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'],
},
{
label: 'MiniMax (China)',
value: 'minimax-cn',
base_url: 'https://api.minimaxi.com/v1',
models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'],
},
{
label: 'Alibaba Cloud',
value: 'alibaba',
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
models: [
'qwen3.5-plus',
'qwen3-coder-plus',
'qwen3-coder-next',
'glm-5',
'glm-4.7',
'kimi-k2.5',
'MiniMax-M2.5',
],
},
{
label: 'Alibaba Cloud (Coding Plan)',
value: 'alibaba-coding-plan',
// NOTE: This is the international (intl) DashScope endpoint, matching upstream
// hermes-agent (auth.py:255). Mainland China DashScope accounts (sk-sp-* keys
// issued by dashscope.aliyun.com) must override via ALIBABA_CODING_PLAN_BASE_URL=
// https://coding.dashscope.aliyuncs.com/v1 (no -intl), since the -intl endpoint
// returns HTTP 401 for those keys.
base_url: 'https://coding-intl.dashscope.aliyuncs.com/v1',
models: [
'qwen3.5-plus',
'qwen3-max-2026-01-23',
'qwen3-coder-next',
'qwen3-coder-plus',
'glm-5',
'glm-4.7',
'kimi-k2.5',
'MiniMax-M2.5',
],
},
{
label: 'Hugging Face',
value: 'huggingface',
base_url: 'https://router.huggingface.co/v1',
models: [
'Qwen/Qwen3.5-397B-A17B',
'Qwen/Qwen3.5-35B-A3B',
'deepseek-ai/DeepSeek-V3.2',
'moonshotai/Kimi-K2.5',
'MiniMaxAI/MiniMax-M2.5',
'zai-org/GLM-5',
'XiaomiMiMo/MiMo-V2-Flash',
'moonshotai/Kimi-K2-Thinking',
],
},
{
label: 'Xiaomi MiMo',
value: 'xiaomi',
base_url: 'https://api.xiaomimimo.com/v1',
models: ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'],
},
{
label: 'Kilo Code',
value: 'kilocode',
base_url: 'https://api.kilo.ai/api/gateway',
models: [
'anthropic/claude-opus-4.6',
'anthropic/claude-sonnet-4.6',
'openai/gpt-5.4',
'google/gemini-3-pro-preview',
'google/gemini-3-flash-preview',
],
},
{
label: 'Vercel AI Gateway',
value: 'ai-gateway',
base_url: 'https://ai-gateway.vercel.sh/v1',
models: [
'anthropic/claude-opus-4.6',
'anthropic/claude-sonnet-4.6',
'anthropic/claude-sonnet-4.5',
'anthropic/claude-haiku-4.5',
'openai/gpt-5',
'openai/gpt-4.1',
'openai/gpt-4.1-mini',
'google/gemini-3-pro-preview',
'google/gemini-3-flash',
'google/gemini-2.5-pro',
'google/gemini-2.5-flash',
'deepseek/deepseek-v3.2',
],
},
{
label: 'CLIProxyAPI',
value: 'cliproxyapi',
base_url: 'http://127.0.0.1:8317/v1',
models: [
'gpt-5.5',
'gpt-5-codex',
'claude-sonnet-4-6',
'claude-sonnet-4-5-20250929',
'gemini-3.1-pro-preview',
'gemini-2.5-pro',
],
},
{
label: 'OpenCode Zen',
value: 'opencode-zen',
base_url: 'https://opencode.ai/zen/v1',
models: [
'gpt-5.4-pro',
'gpt-5.4',
'gpt-5.3-codex',
'gpt-5.3-codex-spark',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.1',
'gpt-5.1-codex',
'gpt-5.1-codex-max',
'gpt-5.1-codex-mini',
'gpt-5',
'gpt-5-codex',
'gpt-5-nano',
'claude-opus-4-6',
'claude-opus-4-5',
'claude-opus-4-1',
'claude-sonnet-4-6',
'claude-sonnet-4-5',
'claude-sonnet-4',
'claude-haiku-4-5',
'claude-3-5-haiku',
'gemini-3.1-pro',
'gemini-3-pro',
'gemini-3-flash',
'minimax-m2.7',
'minimax-m2.5',
'minimax-m2.5-free',
'minimax-m2.1',
'glm-5',
'glm-4.7',
'glm-4.6',
'kimi-k2.5',
'kimi-k2-thinking',
'kimi-k2',
'qwen3-coder',
'big-pickle',
],
},
{
label: 'OpenCode Go',
value: 'opencode-go',
base_url: 'https://opencode.ai/zen/go/v1',
models: ['glm-5.1', 'glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'],
},
{
label: 'OpenAI Codex',
value: 'openai-codex',
base_url: 'https://chatgpt.com/backend-api/codex',
models: ['gpt-5.5', 'gpt-5.4-mini', 'gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini'],
},
{
label: 'Arcee AI',
value: 'arcee',
base_url: 'https://api.arcee.ai/v1',
models: ['trinity-large-thinking', 'trinity-large-preview', 'trinity-mini'],
},
{
label: 'OpenRouter',
value: 'openrouter',
base_url: 'https://openrouter.ai/api/v1',
models: [],
},
{
label: 'GitHub Copilot',
value: 'copilot',
base_url: 'https://api.githubcopilot.com',
models: [
'gpt-5.4',
'gpt-5.4-mini',
'gpt-5-mini',
'gpt-5.3-codex',
'gpt-5.2-codex',
'gpt-4.1',
'gpt-4o',
'gpt-4o-mini',
'claude-sonnet-4.6',
'claude-sonnet-4',
'claude-sonnet-4.5',
'claude-haiku-4.5',
'gemini-3.1-pro-preview',
'gemini-3-pro-preview',
'gemini-3-flash-preview',
'gemini-2.5-pro',
'grok-code-fast-1',
],
},
]
/** Build a Record<providerKey, models[]> for backend lookup */
export function buildProviderModelMap(): Record<string, string[]> {
const map: Record<string, string[]> = {}
for (const p of PROVIDER_PRESETS) {
if (p.models.length > 0) {
map[p.value] = p.models
}
}
return map
}
+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,
@@ -240,12 +240,12 @@ watch(hermesSessionsLoaded, (loaded) => {
if (loaded && hermesSessions.value.length > 0) {
// Only auto-load if no session is currently active
if (!historySessionId.value || !hermesSessions.value.find(s => s.id === historySessionId.value)) {
// Find first CLI session
// Find first CLI session.
const firstCliSession = hermesSessions.value.find(s => s.source === 'cli')
if (firstCliSession) {
// Ensure the CLI group is expanded
if (collapsedGroups.value.has('cli')) {
collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== 'cli'))
if (collapsedGroups.value.has(firstCliSession.source)) {
collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== firstCliSession.source))
}
// Load session details
handleSessionClick(firstCliSession.id)