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
@@ -2,6 +2,7 @@
import { renameSession, setSessionWorkspace, batchDeleteSessions, exportSession } from "@/api/hermes/sessions";
import { useChatStore, type Session } from "@/stores/hermes/chat";
import { useAppStore } from "@/stores/hermes/app";
import { useProfilesStore } from "@/stores/hermes/profiles";
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
import {
NButton,
@@ -16,7 +17,6 @@ import {
} from "naive-ui";
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { getSourceLabel } from "@/shared/session-display";
import { copyToClipboard } from "@/utils/clipboard";
import FolderPicker from "./FolderPicker.vue";
import ChatInput from "./ChatInput.vue";
@@ -28,6 +28,7 @@ import OutlinePanel from "./OutlinePanel.vue";
const chatStore = useChatStore();
const appStore = useAppStore();
const profilesStore = useProfilesStore();
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore();
const message = useMessage();
const { t } = useI18n();
@@ -41,6 +42,8 @@ const currentMode = ref<"chat" | "live">("chat");
// Batch selection mode
const isBatchMode = ref(false);
const selectedSessionIds = ref<Set<string>>(new Set());
const showBatchDeleteConfirm = ref(false);
const isBatchDeleting = ref(false);
// Initialize synchronously from the media query so first paint is correct.
// On narrow viewports the session list is an absolute-positioned overlay
@@ -71,6 +74,9 @@ onMounted(() => {
mobileQuery = window.matchMedia("(max-width: 768px)");
handleMobileChange(mobileQuery);
mobileQuery.addEventListener("change", handleMobileChange);
if (profilesStore.profiles.length === 0) {
void profilesStore.fetchProfiles();
}
});
onUnmounted(() => {
@@ -80,15 +86,18 @@ const showRenameModal = ref(false);
const renameValue = ref("");
const renameSessionId = ref<string | null>(null);
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null);
const collapsedGroups = ref<Set<string>>(
new Set(JSON.parse(localStorage.getItem("hermes_collapsed_groups") || "[]")),
);
const sessionProfileFilter = ref<string | null>(null);
const profileFilterOptions = computed(() => [
{ label: t("chat.allProfiles"), value: "__all__" },
...profilesStore.profiles.map((profile) => ({
label: profile.name,
value: profile.name,
})),
]);
// Source sort order: api_server first, cron last, others alphabetical
function sourceSortKey(source: string): number {
if (source === "api_server") return -1;
if (source === "cron") return 999;
return 0;
async function handleProfileFilterChange(value: string) {
sessionProfileFilter.value = value === "__all__" ? null : value;
await chatStore.loadSessions(sessionProfileFilter.value);
}
function sortSessionsWithActiveFirst(items: Session[]): Session[] {
@@ -97,13 +106,6 @@ function sortSessionsWithActiveFirst(items: Session[]): Session[] {
});
}
// Group sessions by source, with sort order
interface SessionGroup {
source: string;
label: string;
sessions: Session[];
}
const pinnedSessions = computed(() =>
sortSessionsWithActiveFirst(
chatStore.sessions.filter((session) =>
@@ -112,80 +114,12 @@ const pinnedSessions = computed(() =>
),
);
const groupedSessions = computed<SessionGroup[]>(() => {
const map = new Map<string, Session[]>();
for (const s of chatStore.sessions) {
if (sessionBrowserPrefsStore.isPinned(s.id)) continue;
const key = s.source || "";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
}
const keys = [...map.keys()].sort((a, b) => {
const ka = sourceSortKey(a);
const kb = sourceSortKey(b);
if (ka !== kb) return ka - kb;
return a.localeCompare(b);
});
return keys.map((key) => ({
source: key,
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) {
collapsedGroups.value = new Set([...collapsedGroups.value, source]);
} else {
collapsedGroups.value = new Set(
groupedSessions.value.map((g) => g.source).filter((s) => s !== source),
);
const group = groupedSessions.value.find((g) => g.source === source);
if (group?.sessions.length) {
chatStore.switchSession(group.sessions[0].id);
}
}
localStorage.setItem(
"hermes_collapsed_groups",
JSON.stringify([...collapsedGroups.value]),
);
}
watch(
groupedSessions,
(groups) => {
if (localStorage.getItem("hermes_collapsed_groups") !== null) {
const activeSource = chatStore.activeSession?.source;
if (activeSource && collapsedGroups.value.has(activeSource)) {
collapsedGroups.value = new Set(
[...collapsedGroups.value].filter(
(source) => source !== activeSource,
),
);
localStorage.setItem(
"hermes_collapsed_groups",
JSON.stringify([...collapsedGroups.value]),
);
}
return;
}
collapsedGroups.value = new Set(
groups.slice(1).map((group) => group.source),
);
localStorage.setItem(
"hermes_collapsed_groups",
JSON.stringify([...collapsedGroups.value]),
);
},
{ once: true },
const unpinnedSessions = computed(() =>
sortSessionsWithActiveFirst(
chatStore.sessions.filter(
(session) => !sessionBrowserPrefsStore.isPinned(session.id),
),
),
);
watch(
@@ -211,39 +145,110 @@ const headerTitle = computed(() =>
: activeSessionTitle.value,
);
const activeSessionSource = computed(() =>
currentMode.value === "chat" ? chatStore.activeSession?.source || "" : "",
);
const activeApproval = computed(() => chatStore.activePendingApproval);
const visibleApproval = computed(() => activeApproval.value);
const showNewChatModal = ref(false);
const newChatProfile = ref<string>("default");
const newChatProvider = ref<string>("");
const newChatModel = ref<string>("");
const newChatLoading = ref(false);
function handleNewChat() {
chatStore.newChat();
function getModelGroupsForProfile(profile: string) {
const profileModels = appStore.profileModelGroups.find(
(entry) => entry.profile === profile,
);
return profileModels?.groups?.length ? profileModels.groups : appStore.modelGroups;
}
function handleNewCliChat() {
const session = chatStore.newCliSession()
chatStore.switchSession(session.id)
function getDefaultModelForProfile(profile: string) {
const groups = getModelGroupsForProfile(profile);
const profileModels = appStore.profileModelGroups.find(
(entry) => entry.profile === profile,
);
const defaultProvider = profileModels?.default_provider || "";
const defaultModel = profileModels?.default || "";
const providerGroup = defaultProvider
? groups.find((group) => group.provider === defaultProvider)
: undefined;
const fallbackGroup = providerGroup || groups.find((group) => group.models.length > 0);
return {
provider: fallbackGroup?.provider || "",
model: fallbackGroup?.models.includes(defaultModel)
? defaultModel
: fallbackGroup?.models[0] || "",
};
}
const newChatOptions = computed(() => [
{
label: "API",
key: "api_server",
},
{
label: "Bridge (beta)",
key: "cli",
},
]);
const newChatProfileOptions = computed(() =>
(profilesStore.profiles.length > 0 ? profilesStore.profiles : [{ name: "default" }]).map((profile) => ({
label: profile.name,
value: profile.name,
})),
);
function handleNewChatSelect(key: string | number) {
if (key === "cli") {
handleNewCliChat();
return;
const newChatModelGroups = computed(() => {
return getModelGroupsForProfile(newChatProfile.value);
});
const newChatProviderOptions = computed(() =>
newChatModelGroups.value.map((group) => ({
label: group.label || group.provider,
value: group.provider,
})),
);
const newChatModelOptions = computed(() => {
const group = newChatModelGroups.value.find(
(item) => item.provider === newChatProvider.value,
);
return (group?.models || []).map((model) => ({
label: appStore.displayModelName(model, group?.provider),
value: model,
}));
});
function syncNewChatModelSelection() {
const defaults = getDefaultModelForProfile(newChatProfile.value);
newChatProvider.value = defaults.provider;
newChatModel.value = defaults.model;
}
async function openNewChatModal() {
showNewChatModal.value = true;
newChatLoading.value = true;
try {
if (profilesStore.profiles.length === 0) await profilesStore.fetchProfiles();
if (appStore.modelGroups.length === 0 && appStore.profileModelGroups.length === 0) {
await appStore.loadModels();
}
newChatProfile.value =
profilesStore.activeProfileName ||
profilesStore.profiles.find((profile) => profile.active)?.name ||
profilesStore.profiles[0]?.name ||
"default";
syncNewChatModelSelection();
} finally {
newChatLoading.value = false;
}
handleNewChat();
}
function handleNewChatProfileChange(value: string) {
newChatProfile.value = value;
syncNewChatModelSelection();
}
function handleNewChatProviderChange(value: string) {
newChatProvider.value = value;
newChatModel.value = newChatModelOptions.value[0]?.value || "";
}
function confirmNewChat() {
chatStore.newChat({
profile: newChatProfile.value,
provider: newChatProvider.value,
model: newChatModel.value,
});
showNewChatModal.value = false;
}
function handleApproval(choice: "once" | "session" | "always" | "deny") {
@@ -266,19 +271,25 @@ function handleDeleteSession(id: string) {
}
function toggleBatchMode() {
if (isBatchDeleting.value) return;
isBatchMode.value = !isBatchMode.value;
if (!isBatchMode.value) {
selectedSessionIds.value.clear();
showBatchDeleteConfirm.value = false;
}
}
function toggleSessionSelection(id: string) {
if (isBatchDeleting.value) return;
if (selectedSessionIds.value.has(id)) {
selectedSessionIds.value.delete(id);
} else {
selectedSessionIds.value.add(id);
}
selectedSessionIds.value = new Set(selectedSessionIds.value);
if (selectedSessionIds.value.size === 0) {
showBatchDeleteConfirm.value = false;
}
}
function isSessionSelected(id: string): boolean {
@@ -286,9 +297,10 @@ function isSessionSelected(id: string): boolean {
}
async function handleBatchDelete() {
if (selectedSessionIds.value.size === 0) return;
if (selectedSessionIds.value.size === 0 || isBatchDeleting.value) return;
const ids = Array.from(selectedSessionIds.value);
isBatchDeleting.value = true;
try {
const result = await batchDeleteSessions(ids);
if (result.deleted > 0) {
@@ -311,12 +323,20 @@ async function handleBatchDelete() {
} catch (err: any) {
message.error(t("chat.batchDeleteFailed"));
} finally {
isBatchDeleting.value = false;
showBatchDeleteConfirm.value = false;
isBatchMode.value = false;
selectedSessionIds.value.clear();
}
}
function handleBatchDeleteConfirm() {
void handleBatchDelete();
return false;
}
function selectAllSessions() {
if (isBatchDeleting.value) return;
selectedSessionIds.value.clear();
for (const session of chatStore.sessions) {
if (session.id !== chatStore.activeSessionId) {
@@ -502,12 +522,21 @@ const sessionModelProvider = ref("");
const sessionModelCustomInput = ref("");
const sessionModelCustomProvider = ref("");
const sessionModelProfile = computed(() => {
const session = chatStore.sessions.find((s) => s.id === sessionModelSessionId.value);
return session?.profile || profilesStore.activeProfileName || "default";
});
const sessionModelBaseGroups = computed(() =>
getModelGroupsForProfile(sessionModelProfile.value),
);
const sessionModelProviderOptions = computed(() =>
appStore.modelGroups.map((group) => ({ label: group.label, value: group.provider })),
sessionModelBaseGroups.value.map((group) => ({ label: group.label, value: group.provider })),
);
const sessionModelGroupsWithCustom = computed(() =>
appStore.modelGroups.map((group) => ({
sessionModelBaseGroups.value.map((group) => ({
...group,
models: [
...group.models,
@@ -534,9 +563,10 @@ const filteredSessionModelGroups = computed(() => {
function openSessionModelModal(sessionId: string) {
const session = chatStore.sessions.find((s) => s.id === sessionId);
const defaults = getDefaultModelForProfile(session?.profile || profilesStore.activeProfileName || "default");
sessionModelSessionId.value = sessionId;
sessionModelValue.value = session?.model || appStore.selectedModel || "";
sessionModelProvider.value = session?.provider || appStore.selectedProvider || "";
sessionModelValue.value = session?.model || defaults.model || "";
sessionModelProvider.value = session?.provider || defaults.provider || "";
sessionModelCustomProvider.value = sessionModelProvider.value;
sessionModelSearch.value = "";
sessionModelCustomInput.value = "";
@@ -565,7 +595,7 @@ function sessionModelAlias(model: string, provider: string) {
}
async function selectSessionModel(model: string, provider: string) {
const meta = appStore.modelGroups.find((group) => group.provider === provider)?.model_meta?.[model];
const meta = sessionModelBaseGroups.value.find((group) => group.provider === provider)?.model_meta?.[model];
if (meta?.disabled || !sessionModelSessionId.value) return;
const ok = await chatStore.switchSessionModel(model, provider, sessionModelSessionId.value);
if (ok) {
@@ -643,7 +673,7 @@ async function handleSessionModelCustomSubmit() {
quaternary
size="tiny"
@click="selectAllSessions"
:disabled="!canSelectAll"
:disabled="!canSelectAll || isBatchDeleting"
:title="t('chat.selectAll')"
>
<template #icon>
@@ -662,10 +692,13 @@ async function handleSessionModelCustomSubmit() {
</NButton>
<NPopconfirm
v-if="isBatchMode && selectedCount > 0"
@positive-click="handleBatchDelete"
v-model:show="showBatchDeleteConfirm"
:positive-button-props="{ loading: isBatchDeleting, disabled: isBatchDeleting }"
:negative-button-props="{ disabled: isBatchDeleting }"
@positive-click="handleBatchDeleteConfirm"
>
<template #trigger>
<NButton quaternary size="tiny" type="error">
<NButton quaternary size="tiny" type="error" :loading="isBatchDeleting" :disabled="isBatchDeleting">
<template #icon>
<svg
width="14"
@@ -688,6 +721,7 @@ async function handleSessionModelCustomSubmit() {
quaternary
size="tiny"
@click="toggleBatchMode"
:disabled="isBatchDeleting"
>
<template #icon>
<svg
@@ -703,34 +737,31 @@ async function handleSessionModelCustomSubmit() {
</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>
<NButton quaternary size="tiny" circle @click="openNewChatModal">
<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>
</div>
</div>
<div v-if="showSessions" class="session-scope-note">
<span>{{ t("chat.sessionScopeHint") }}</span>
<RouterLink class="session-scope-link" :to="{ name: 'hermes.history' }">
{{ t("chat.openHistory") }}
</RouterLink>
<div v-if="showSessions" class="session-profile-filter">
<NSelect
:value="sessionProfileFilter || '__all__'"
:options="profileFilterOptions"
size="small"
:loading="profilesStore.loading"
@update:value="handleProfileFilterChange"
/>
</div>
<div v-if="showSessions" class="session-items">
<div
@@ -761,6 +792,7 @@ async function handleSessionModelCustomSubmit() {
:streaming="chatStore.isSessionLive(s.id)"
:selectable="isBatchMode"
:selected="isSessionSelected(s.id)"
:show-profile="true"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
@delete="handleDeleteSession(s.id)"
@@ -768,44 +800,25 @@ async function handleSessionModelCustomSubmit() {
/>
</template>
<template v-for="group in groupedSessions" :key="group.source">
<div class="session-group-header" @click="toggleGroup(group.source)">
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="group-chevron"
:class="{ collapsed: collapsedGroups.has(group.source) }"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="session-group-label">{{ group.label }}</span>
<span class="session-group-count">{{ group.sessions.length }}</span>
</div>
<template v-if="!collapsedGroups.has(group.source)">
<SessionListItem
v-for="s in group.sessions"
:key="s.id"
:session="s"
:active="s.id === chatStore.activeSessionId"
:pinned="false"
:can-delete="
s.id !== chatStore.activeSessionId ||
chatStore.sessions.length > 1
"
:streaming="chatStore.isSessionLive(s.id)"
:selectable="isBatchMode"
:selected="isSessionSelected(s.id)"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
@delete="handleDeleteSession(s.id)"
@toggle-select="toggleSessionSelection(s.id)"
/>
</template>
</template>
<SessionListItem
v-for="s in unpinnedSessions"
:key="s.id"
:session="s"
:active="s.id === chatStore.activeSessionId"
:pinned="false"
:can-delete="
s.id !== chatStore.activeSessionId ||
chatStore.sessions.length > 1
"
:streaming="chatStore.isSessionLive(s.id)"
:selectable="isBatchMode"
:selected="isSessionSelected(s.id)"
:show-profile="true"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
@delete="handleDeleteSession(s.id)"
@toggle-select="toggleSessionSelection(s.id)"
/>
</div>
</aside>
@@ -946,6 +959,56 @@ async function handleSessionModelCustomSubmit() {
</div>
</NModal>
<NModal
v-model:show="showNewChatModal"
preset="card"
:title="t('chat.newChat')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="true"
>
<div class="new-chat-form">
<label class="new-chat-field">
<span class="new-chat-label">{{ t("sidebar.profiles") }}</span>
<NSelect
:value="newChatProfile"
:options="newChatProfileOptions"
:loading="newChatLoading || profilesStore.loading"
@update:value="handleNewChatProfileChange"
/>
</label>
<label class="new-chat-field">
<span class="new-chat-label">{{ t("models.provider") }}</span>
<NSelect
:value="newChatProvider"
:options="newChatProviderOptions"
:disabled="newChatLoading"
@update:value="handleNewChatProviderChange"
/>
</label>
<label class="new-chat-field">
<span class="new-chat-label">{{ t("models.models") }}</span>
<NSelect
v-model:value="newChatModel"
:options="newChatModelOptions"
:disabled="newChatLoading || !newChatProvider"
filterable
/>
</label>
</div>
<template #footer>
<div class="new-chat-actions">
<NButton @click="showNewChatModal = false">{{ t("common.cancel") }}</NButton>
<NButton
type="primary"
:disabled="!newChatProfile || !newChatProvider || !newChatModel"
@click="confirmNewChat"
>
{{ t("chat.newChat") }}
</NButton>
</div>
</template>
</NModal>
<div class="chat-main">
<header class="chat-header">
<div class="header-left">
@@ -973,9 +1036,6 @@ async function handleSessionModelCustomSubmit() {
</template>
</NButton>
<span class="header-session-title">{{ headerTitle }}</span>
<span v-if="activeSessionSource" class="source-badge">{{
getChatSourceLabel(activeSessionSource)
}}</span>
<span
v-if="chatStore.activeSession?.workspace"
class="workspace-badge"
@@ -1041,28 +1101,22 @@ async function handleSessionModelCustomSubmit() {
</template>
{{ t("chat.copySessionId") }}
</NTooltip>
<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>
<NButton size="small" :circle="isMobile" @click="openNewChatModal">
<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>
</template>
</div>
</header>
@@ -1460,27 +1514,32 @@ async function handleSessionModelCustomSubmit() {
line-height: 22px;
}
.session-scope-note {
margin: 0 12px 10px;
padding: 8px 10px;
border: 1px solid rgba($accent-primary, 0.16);
border-radius: $radius-sm;
background: rgba($accent-primary, 0.06);
color: $text-secondary;
font-size: 11px;
line-height: 1.45;
.session-profile-filter {
margin: 0 8px 10px;
}
.session-scope-link {
display: inline-block;
margin-left: 4px;
color: $accent-primary;
font-weight: 500;
text-decoration: none;
.new-chat-form {
display: flex;
flex-direction: column;
gap: 14px;
}
&:hover {
text-decoration: underline;
}
.new-chat-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.new-chat-label {
font-size: 12px;
color: $text-muted;
font-weight: 500;
}
.new-chat-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.session-group-header {