Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/client/src/api/hermes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface AgentConfig {
restart_drain_timeout?: number
service_tier?: string
tool_use_enforcement?: string
reasoning_effort?: string
}

export interface MemoryConfig {
Expand Down
32 changes: 31 additions & 1 deletion packages/client/src/api/hermes/download.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getActiveProfileName, getApiKey, getBaseUrlValue } from '../client'
import { getActiveProfileName, getApiKey, getBaseUrlValue, request } from '../client'

/**
* Construct a download URL with auth token as query parameter.
Expand Down Expand Up @@ -69,3 +69,33 @@ export async function fetchFileText(filePath: string, fileName?: string): Promis
}
return res.text()
}

/**
* 用系统默认应用打开文件
*/
export async function openFileWithDefault(filePath: string): Promise<boolean> {
try {
await request('/api/hermes/open-file', {
method: 'POST',
body: JSON.stringify({ path: filePath }),
})
return true
} catch {
return false
}
}

/**
* 在文件浏览器中打开文件所在目录并选中文件
*/
export async function openInExplorer(filePath: string): Promise<boolean> {
try {
await request('/api/hermes/open-in-explorer', {
method: 'POST',
body: JSON.stringify({ path: filePath }),
})
return true
} catch {
return false
}
}
25 changes: 23 additions & 2 deletions packages/client/src/api/hermes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface SessionSummary {
cost_status: string
workspace?: string | null
webui_imported?: boolean
archived?: number
}

export interface SessionDetail extends SessionSummary {
Expand Down Expand Up @@ -59,11 +60,12 @@ export interface HermesMessage {
reasoning: string | null
}

export async function fetchSessions(source?: string, limit?: number, profile?: string): Promise<SessionSummary[]> {
export async function fetchSessions(source?: string, limit?: number, profile?: string, includeArchived = false): Promise<SessionSummary[]> {
const params = new URLSearchParams()
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
if (profile) params.set('profile', profile)
if (includeArchived) params.set('includeArchived', 'true')
const query = params.toString()
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions${query ? `?${query}` : ''}`)
return res.sessions
Expand All @@ -72,11 +74,12 @@ export async function fetchSessions(source?: string, limit?: number, profile?: s
/**
* Fetch Hermes sessions only (exclude api_server source)
*/
export async function fetchHermesSessions(source?: string, limit?: number, profile?: string | null): Promise<SessionSummary[]> {
export async function fetchHermesSessions(source?: string, limit?: number, profile?: string | null, includeArchived = false): Promise<SessionSummary[]> {
const params = new URLSearchParams()
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
if (profile) params.set('profile', profile)
if (includeArchived) params.set('includeArchived', 'true')
const query = params.toString()
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions/hermes${query ? `?${query}` : ''}`)
return res.sessions
Expand Down Expand Up @@ -226,6 +229,24 @@ export async function setSessionModel(id: string, model: string, provider: strin
}
}

export async function archiveSession(id: string): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/archive`, { method: 'POST' })
return true
} catch {
return false
}
}

export async function unarchiveSession(id: string): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/unarchive`, { method: 'POST' })
return true
} catch {
return false
}
}

export async function exportSession(id: string, mode: 'full' | 'compressed' = 'full', ext: 'json' | 'txt' = 'json'): Promise<void> {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
Expand Down
181 changes: 177 additions & 4 deletions packages/client/src/components/hermes/chat/ChatPanel.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { renameSession, setSessionWorkspace, batchDeleteSessions, exportSession } from "@/api/hermes/sessions";
import { renameSession, setSessionWorkspace, batchDeleteSessions, exportSession, archiveSession, unarchiveSession, fetchSessions } from "@/api/hermes/sessions";
import { useChatStore, type Session } from "@/stores/hermes/chat";
import { useAppStore } from "@/stores/hermes/app";
import { useProfilesStore } from "@/stores/hermes/profiles";
Expand Down Expand Up @@ -120,6 +120,12 @@ const profileFilterOptions = computed(() => [
async function handleProfileFilterChange(value: string) {
chatStore.sessionProfileFilter = value === "__all__" ? null : value;
await chatStore.loadSessions(chatStore.sessionProfileFilter);

// 切换 profile 时清空归档列表,下次展开时重新加载
archivedSessions.value = [];
if (showArchivedSection.value) {
loadArchivedSessions();
}
}

function sortSessionsWithActiveFirst(items: Session[]): Session[] {
Expand All @@ -131,19 +137,58 @@ function sortSessionsWithActiveFirst(items: Session[]): Session[] {
const pinnedSessions = computed(() =>
sortSessionsWithActiveFirst(
chatStore.sessions.filter((session) =>
sessionBrowserPrefsStore.isPinned(session.id),
sessionBrowserPrefsStore.isPinned(session.id) && !session.archived,
),
),
);

const unpinnedSessions = computed(() =>
sortSessionsWithActiveFirst(
chatStore.sessions.filter(
(session) => !sessionBrowserPrefsStore.isPinned(session.id),
(session) => !sessionBrowserPrefsStore.isPinned(session.id) && !session.archived,
),
),
);

// 归档会话
const archivedSessions = ref<Session[]>([]);
const showArchivedSection = ref(false);
const archivedLoading = ref(false);

async function loadArchivedSessions() {
archivedLoading.value = true;
try {
const profile = chatStore.sessionProfileFilter || undefined;
const summaries = await fetchSessions(undefined, 2000, profile, true);
const archived = summaries
.filter(s => s.archived === 1)
.map(s => ({
id: s.id,
profile: s.profile || undefined,
title: s.title || '',
source: s.source,
createdAt: s.started_at * 1000,
updatedAt: (s.last_active || s.ended_at || s.started_at) * 1000,
model: s.model,
provider: s.provider,
messageCount: s.message_count,
} as Session))
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
archivedSessions.value = archived;
} catch {
// ignore
} finally {
archivedLoading.value = false;
}
}

function toggleArchivedSection() {
showArchivedSection.value = !showArchivedSection.value;
if (showArchivedSection.value && archivedSessions.value.length === 0) {
loadArchivedSessions();
}
}

watch(
() => [
chatStore.sessionsLoaded,
Expand Down Expand Up @@ -428,15 +473,27 @@ const contextSessionPinned = computed(() =>
);
const contextSession = computed(() =>
contextSessionId.value
? chatStore.sessions.find((session) => session.id === contextSessionId.value) || null
? chatStore.sessions.find((session) => session.id === contextSessionId.value)
|| archivedSessions.value.find((session) => session.id === contextSessionId.value)
|| null
: null,
);

const contextSessionArchived = computed(() =>
contextSessionId.value
? archivedSessions.value.some(s => s.id === contextSessionId.value)
: false,
);

const contextMenuOptions = computed(() => {
const options: DropdownOption[] = [{
label: t(contextSessionPinned.value ? "chat.unpin" : "chat.pin"),
key: "pin",
},
{
label: t(contextSessionArchived.value ? "chat.unarchive" : "chat.archive"),
key: "archive",
},
{ label: t("chat.rename"), key: "rename" },
{ label: t("chat.setWorkspace"), key: "workspace" }]

Expand Down Expand Up @@ -499,6 +556,40 @@ async function handleContextMenuSelect(key: string) {
sessionBrowserPrefsStore.togglePinned(contextSessionId.value);
return;
}
if (key === "archive") {
const id = contextSessionId.value;
if (contextSessionArchived.value) {
const ok = await unarchiveSession(id);
if (ok) {
archivedSessions.value = archivedSessions.value.filter(s => s.id !== id);
await chatStore.loadSessions(chatStore.sessionProfileFilter);
message.success(t("chat.unarchived"));
}
} else {
const ok = await archiveSession(id);
if (ok) {
// 保存归档会话的 ID,避免 pruneMissingSessions 清除其 pin 状态
const archivedSessionIds = [...archivedSessions.value.map(s => s.id), id];

if (chatStore.activeSessionId === id) {
// 当前活跃会话被归档,切换到下一个
const remaining = chatStore.sessions.filter(s => s.id !== id);
if (remaining.length > 0) {
await chatStore.switchSession(remaining[0].id);
}
}
await chatStore.loadSessions(chatStore.sessionProfileFilter);

// 手动触发 prune,包含归档会话 ID
const allSessionIds = [...chatStore.sessions.map(s => s.id), ...archivedSessionIds];
sessionBrowserPrefsStore.pruneMissingSessions(allSessionIds);

if (showArchivedSection.value) loadArchivedSessions();
message.success(t("chat.archived"));
}
}
return;
}
if (key === "copy-link") {
copySessionLink(contextSessionId.value);
} else if (key === "copy-id") {
Expand Down Expand Up @@ -902,6 +993,44 @@ async function handleSessionModelCustomSubmit() {
@toggle-select="toggleSessionSelection(s)"
/>
</div>

<!-- 归档会话:固定在侧边栏底部 -->
<div v-if="showSessions" class="archived-section">
<button class="archived-toggle" @click="toggleArchivedSection">
<svg
class="archived-chevron"
:class="{ 'archived-chevron--open': showArchivedSection }"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="archived-toggle-label">{{ t("chat.archivedSessions") }}</span>
<span v-if="archivedSessions.length > 0" class="session-group-count">{{ archivedSessions.length }}</span>
</button>
<div v-if="showArchivedSection" class="archived-list">
<div v-if="archivedLoading" class="session-loading">{{ t("common.loading") }}</div>
<div v-else-if="archivedSessions.length === 0" class="session-empty" style="padding: 8px 12px;">{{ t("chat.noArchivedSessions") }}</div>
<SessionListItem
v-for="s in archivedSessions"
:key="`archived-${s.id}`"
:session="s"
:active="s.id === chatStore.activeSessionId"
:pinned="false"
:can-delete="false"
:show-profile="false"
:to="sessionHref(s.id)"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
/>
</div>
</div>
</aside>

<NDropdown
Expand Down Expand Up @@ -1721,6 +1850,50 @@ async function handleSessionModelCustomSubmit() {
font-weight: 400;
}

.archived-section {
flex-shrink: 0;
border-top: 1px solid var(--border-color, rgba(128, 128, 128, 0.15));
}

.archived-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
cursor: pointer;
color: $text-muted;
font-size: 12px;
border-radius: $radius-sm;
transition: background $transition-fast;

&:hover {
background: var(--bg-hover, rgba(128, 128, 128, 0.08));
}
}

.archived-chevron {
transition: transform $transition-fast;
flex-shrink: 0;

&--open {
transform: rotate(90deg);
}
}

.archived-toggle-label {
flex: 1;
text-align: left;
}

.archived-list {
max-height: 300px;
overflow-y: auto;
padding: 0 0 4px;
}

.session-items {
flex: 1;
overflow-y: auto;
Expand Down
Loading