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:
@@ -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
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '批量选择',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user