[codex] Fix profile-aware session deep links (#962)
* feat: add session deep links for chats * feat: add deep links for history and group chat * Fix profile-aware session deep links --------- Co-authored-by: Maxim Kirilyuk <werserk@inbox.ru>
This commit is contained in:
@@ -15,6 +15,7 @@ export interface StartRunRequest {
|
|||||||
input: string | ContentBlock[]
|
input: string | ContentBlock[]
|
||||||
instructions?: string
|
instructions?: string
|
||||||
session_id?: string
|
session_id?: string
|
||||||
|
profile?: string
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
model_groups?: Array<{ provider: string; models: string[] }>
|
model_groups?: Array<{ provider: string; models: string[] }>
|
||||||
@@ -77,6 +78,7 @@ export interface RunEvent {
|
|||||||
|
|
||||||
let chatRunSocket: Socket | null = null
|
let chatRunSocket: Socket | null = null
|
||||||
let globalListenersRegistered = false
|
let globalListenersRegistered = false
|
||||||
|
let chatRunSocketProfile: string | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session event handlers map
|
* Session event handlers map
|
||||||
@@ -429,29 +431,36 @@ export function getChatRunSocket(): Socket | null {
|
|||||||
return chatRunSocket
|
return chatRunSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
export function connectChatRun(): Socket {
|
export function connectChatRun(requestedProfile?: string | null): Socket {
|
||||||
if (chatRunSocket?.connected) return chatRunSocket
|
const normalizedRequestedProfile = requestedProfile?.trim() || null
|
||||||
|
if (chatRunSocket?.connected && (!normalizedRequestedProfile || chatRunSocketProfile === normalizedRequestedProfile)) {
|
||||||
|
return chatRunSocket
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up old socket to prevent duplicate event listeners
|
// Clean up old socket to prevent duplicate event listeners
|
||||||
if (chatRunSocket) {
|
if (chatRunSocket) {
|
||||||
chatRunSocket.removeAllListeners()
|
chatRunSocket.removeAllListeners()
|
||||||
chatRunSocket.disconnect()
|
chatRunSocket.disconnect()
|
||||||
globalListenersRegistered = false
|
globalListenersRegistered = false
|
||||||
|
chatRunSocketProfile = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = getBaseUrlValue()
|
const baseUrl = getBaseUrlValue()
|
||||||
const token = getApiKey()
|
const token = getApiKey()
|
||||||
|
|
||||||
// Get active profile from store (authoritative source)
|
// Get active profile from store (authoritative source)
|
||||||
let profile = 'default'
|
let profile = normalizedRequestedProfile || 'default'
|
||||||
try {
|
try {
|
||||||
|
if (!normalizedRequestedProfile) {
|
||||||
const { useProfilesStore } = require('@/stores/hermes/profiles')
|
const { useProfilesStore } = require('@/stores/hermes/profiles')
|
||||||
const profilesStore = useProfilesStore()
|
const profilesStore = useProfilesStore()
|
||||||
profile = profilesStore.activeProfileName || 'default'
|
profile = profilesStore.activeProfileName || 'default'
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to localStorage during early initialization
|
// Fallback to localStorage during early initialization
|
||||||
profile = localStorage.getItem('hermes_active_profile_name') || 'default'
|
profile = normalizedRequestedProfile || localStorage.getItem('hermes_active_profile_name') || 'default'
|
||||||
}
|
}
|
||||||
|
chatRunSocketProfile = profile
|
||||||
|
|
||||||
chatRunSocket = io(`${baseUrl}/chat-run`, {
|
chatRunSocket = io(`${baseUrl}/chat-run`, {
|
||||||
auth: { token },
|
auth: { token },
|
||||||
@@ -506,6 +515,7 @@ export function disconnectChatRun(): void {
|
|||||||
if (chatRunSocket) {
|
if (chatRunSocket) {
|
||||||
chatRunSocket.disconnect()
|
chatRunSocket.disconnect()
|
||||||
chatRunSocket = null
|
chatRunSocket = null
|
||||||
|
chatRunSocketProfile = null
|
||||||
globalListenersRegistered = false
|
globalListenersRegistered = false
|
||||||
sessionEventHandlers.clear()
|
sessionEventHandlers.clear()
|
||||||
}
|
}
|
||||||
@@ -533,11 +543,12 @@ function removeSocketListener(socket: Socket, event: string, handler: (...args:
|
|||||||
export function resumeSession(
|
export function resumeSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
onResumed: (data: { session_id: string; messages: any[]; isWorking: boolean; isAborting?: boolean; events: any[]; inputTokens?: number; outputTokens?: number; contextTokens?: number; queueLength?: number; queueMessages?: RunEvent['queued_messages'] }) => void,
|
onResumed: (data: { session_id: string; messages: any[]; isWorking: boolean; isAborting?: boolean; events: any[]; inputTokens?: number; outputTokens?: number; contextTokens?: number; queueLength?: number; queueMessages?: RunEvent['queued_messages'] }) => void,
|
||||||
|
profile?: string | null,
|
||||||
): Socket {
|
): Socket {
|
||||||
const socket = connectChatRun()
|
const socket = connectChatRun(profile)
|
||||||
|
|
||||||
socket.once('resumed', onResumed)
|
socket.once('resumed', onResumed)
|
||||||
socket.emit('resume', { session_id: sessionId })
|
socket.emit('resume', { session_id: sessionId, ...(profile ? { profile } : {}) })
|
||||||
|
|
||||||
return socket
|
return socket
|
||||||
}
|
}
|
||||||
@@ -555,7 +566,7 @@ export function startRunViaSocket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let closed = false
|
let closed = false
|
||||||
const socket = connectChatRun()
|
const socket = connectChatRun(body.profile)
|
||||||
const handleSocketError = (err: Error) => {
|
const handleSocketError = (err: Error) => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
closed = true
|
closed = true
|
||||||
|
|||||||
@@ -62,10 +62,11 @@ export async function fetchSessions(source?: string, limit?: number, profile?: s
|
|||||||
/**
|
/**
|
||||||
* Fetch Hermes sessions only (exclude api_server source)
|
* Fetch Hermes sessions only (exclude api_server source)
|
||||||
*/
|
*/
|
||||||
export async function fetchHermesSessions(source?: string, limit?: number): Promise<SessionSummary[]> {
|
export async function fetchHermesSessions(source?: string, limit?: number, profile?: string | null): Promise<SessionSummary[]> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (source) params.set('source', source)
|
if (source) params.set('source', source)
|
||||||
if (limit) params.set('limit', String(limit))
|
if (limit) params.set('limit', String(limit))
|
||||||
|
if (profile) params.set('profile', profile)
|
||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions/hermes${query ? `?${query}` : ''}`)
|
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions/hermes${query ? `?${query}` : ''}`)
|
||||||
return res.sessions
|
return res.sessions
|
||||||
@@ -82,9 +83,12 @@ export async function searchSessions(q: string, source?: string, limit?: number,
|
|||||||
return res.results
|
return res.results
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSession(id: string): Promise<SessionDetail | null> {
|
export async function fetchSession(id: string, profile?: string | null): Promise<SessionDetail | null> {
|
||||||
try {
|
try {
|
||||||
const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/${id}`)
|
const params = new URLSearchParams()
|
||||||
|
if (profile) params.set('profile', profile)
|
||||||
|
const query = params.toString()
|
||||||
|
const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/${id}${query ? `?${query}` : ''}`)
|
||||||
return res.session
|
return res.session
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type DropdownOption,
|
type DropdownOption,
|
||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { copyToClipboard } from "@/utils/clipboard";
|
import { copyToClipboard } from "@/utils/clipboard";
|
||||||
import FolderPicker from "./FolderPicker.vue";
|
import FolderPicker from "./FolderPicker.vue";
|
||||||
@@ -30,6 +31,7 @@ const chatStore = useChatStore();
|
|||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const profilesStore = useProfilesStore();
|
const profilesStore = useProfilesStore();
|
||||||
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore();
|
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore();
|
||||||
|
const router = useRouter();
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -58,8 +60,13 @@ const showSessions = ref(
|
|||||||
let mobileQuery: MediaQueryList | null = null;
|
let mobileQuery: MediaQueryList | null = null;
|
||||||
const isMobile = ref(false);
|
const isMobile = ref(false);
|
||||||
|
|
||||||
function handleSessionClick(sessionId: string) {
|
async function handleSessionClick(sessionId: string) {
|
||||||
chatStore.switchSession(sessionId);
|
const session = chatStore.sessions.find((item) => item.id === sessionId);
|
||||||
|
await router.push({
|
||||||
|
name: "hermes.session",
|
||||||
|
params: { sessionId },
|
||||||
|
query: session?.profile ? { profile: session.profile } : undefined,
|
||||||
|
});
|
||||||
if (mobileQuery?.matches) showSessions.value = false;
|
if (mobileQuery?.matches) showSessions.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,12 +249,17 @@ function handleNewChatProviderChange(value: string) {
|
|||||||
newChatModel.value = newChatModelOptions.value[0]?.value || "";
|
newChatModel.value = newChatModelOptions.value[0]?.value || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmNewChat() {
|
async function confirmNewChat() {
|
||||||
chatStore.newChat({
|
const session = chatStore.newChat({
|
||||||
profile: newChatProfile.value,
|
profile: newChatProfile.value,
|
||||||
provider: newChatProvider.value,
|
provider: newChatProvider.value,
|
||||||
model: newChatModel.value,
|
model: newChatModel.value,
|
||||||
});
|
});
|
||||||
|
await router.push({
|
||||||
|
name: "hermes.session",
|
||||||
|
params: { sessionId: session.id },
|
||||||
|
query: session.profile ? { profile: session.profile } : undefined,
|
||||||
|
});
|
||||||
showNewChatModal.value = false;
|
showNewChatModal.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +267,28 @@ function handleApproval(choice: "once" | "session" | "always" | "deny") {
|
|||||||
chatStore.respondApproval(choice);
|
chatStore.respondApproval(choice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionProfile(sessionId: string): string | null {
|
||||||
|
return chatStore.sessions.find((session) => session.id === sessionId)?.profile || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionUrl(sessionId: string, profile?: string | null): string {
|
||||||
|
const href = router.resolve({
|
||||||
|
name: "hermes.session",
|
||||||
|
params: { sessionId },
|
||||||
|
query: profile ? { profile } : undefined,
|
||||||
|
}).href;
|
||||||
|
return `${window.location.origin}${window.location.pathname}${href}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySessionLink(id?: string) {
|
||||||
|
const sessionId = id || chatStore.activeSessionId;
|
||||||
|
if (sessionId) {
|
||||||
|
const ok = await copyToClipboard(buildSessionUrl(sessionId, sessionProfile(sessionId)));
|
||||||
|
if (ok) message.success(t("common.copied"));
|
||||||
|
else message.error(t("common.copied") + " ✗");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copySessionId(id?: string) {
|
async function copySessionId(id?: string) {
|
||||||
const sessionId = id || chatStore.activeSessionId;
|
const sessionId = id || chatStore.activeSessionId;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
@@ -397,6 +431,7 @@ const contextMenuOptions = computed(() => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
options.push({ label: t("chat.copySessionLink"), key: "copy-link" })
|
||||||
options.push({ label: t("chat.copySessionId"), key: "copy-id" })
|
options.push({ label: t("chat.copySessionId"), key: "copy-id" })
|
||||||
return options
|
return options
|
||||||
});
|
});
|
||||||
@@ -428,7 +463,9 @@ async function handleContextMenuSelect(key: string) {
|
|||||||
sessionBrowserPrefsStore.togglePinned(contextSessionId.value);
|
sessionBrowserPrefsStore.togglePinned(contextSessionId.value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key === "copy-id") {
|
if (key === "copy-link") {
|
||||||
|
copySessionLink(contextSessionId.value);
|
||||||
|
} else if (key === "copy-id") {
|
||||||
copySessionId(contextSessionId.value);
|
copySessionId(contextSessionId.value);
|
||||||
} else if (parseExportKey(key)) {
|
} else if (parseExportKey(key)) {
|
||||||
const { mode, ext } = parseExportKey(key)!;
|
const { mode, ext } = parseExportKey(key)!;
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useMessage, NInput, NButton, NSpace, NSelect, NPopover, NPopconfirm, NInputNumber } from 'naive-ui'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useMessage, NInput, NButton, NSpace, NSelect, NPopover, NPopconfirm, NInputNumber, NDropdown, type DropdownOption } from 'naive-ui'
|
||||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||||
import { updateRoomConfig, forceCompress } from '@/api/hermes/group-chat'
|
import { updateRoomConfig, forceCompress } from '@/api/hermes/group-chat'
|
||||||
import GroupMessageList from './GroupMessageList.vue'
|
import GroupMessageList from './GroupMessageList.vue'
|
||||||
import GroupChatInput from './GroupChatInput.vue'
|
import GroupChatInput from './GroupChatInput.vue'
|
||||||
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
|
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
import type { Attachment } from '@/stores/hermes/chat'
|
import type { Attachment } from '@/stores/hermes/chat'
|
||||||
import type { RoomAgent } from '@/api/hermes/group-chat'
|
import type { RoomAgent } from '@/api/hermes/group-chat'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const store = useGroupChatStore()
|
const store = useGroupChatStore()
|
||||||
const profilesStore = useProfilesStore()
|
const profilesStore = useProfilesStore()
|
||||||
@@ -29,6 +32,10 @@ const agentDescription = ref('')
|
|||||||
const cloneSourceRoomId = ref<string | null>(null)
|
const cloneSourceRoomId = ref<string | null>(null)
|
||||||
const cloneRoomName = ref('')
|
const cloneRoomName = ref('')
|
||||||
const cloneInviteCode = ref('')
|
const cloneInviteCode = ref('')
|
||||||
|
const contextRoomId = ref<string | null>(null)
|
||||||
|
const showRoomContextMenu = ref(false)
|
||||||
|
const roomContextMenuX = ref(0)
|
||||||
|
const roomContextMenuY = ref(0)
|
||||||
|
|
||||||
const profileOptions = computed(() =>
|
const profileOptions = computed(() =>
|
||||||
profilesStore.profiles.map(p => ({ label: p.name, value: p.name }))
|
profilesStore.profiles.map(p => ({ label: p.name, value: p.name }))
|
||||||
@@ -94,7 +101,7 @@ async function handleCreateRoom(name: string, inviteCode: string, userName: stri
|
|||||||
const failureMessage = formatAgentFailures(res.agentResults)
|
const failureMessage = formatAgentFailures(res.agentResults)
|
||||||
if (failureMessage) message.warning(failureMessage)
|
if (failureMessage) message.warning(failureMessage)
|
||||||
else message.success(t('groupChat.roomCreated'))
|
else message.success(t('groupChat.roomCreated'))
|
||||||
await store.joinRoom(res.room.id)
|
await router.push({ name: 'hermes.groupChatRoom', params: { roomId: res.room.id } })
|
||||||
} catch {
|
} catch {
|
||||||
message.error(t('common.saveFailed'))
|
message.error(t('common.saveFailed'))
|
||||||
}
|
}
|
||||||
@@ -103,12 +110,54 @@ async function handleCreateRoom(name: string, inviteCode: string, userName: stri
|
|||||||
async function handleDeleteRoom(roomId: string) {
|
async function handleDeleteRoom(roomId: string) {
|
||||||
try {
|
try {
|
||||||
await store.deleteRoom(roomId)
|
await store.deleteRoom(roomId)
|
||||||
|
if (store.currentRoomId === roomId) {
|
||||||
|
await router.replace({ name: 'hermes.groupChat' })
|
||||||
|
}
|
||||||
message.success(t('groupChat.roomDeleted'))
|
message.success(t('groupChat.roomDeleted'))
|
||||||
} catch {
|
} catch {
|
||||||
message.error(t('common.saveFailed'))
|
message.error(t('common.saveFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRoomUrl(roomId: string) {
|
||||||
|
const href = router.resolve({ name: 'hermes.groupChatRoom', params: { roomId } }).href
|
||||||
|
return `${window.location.origin}${window.location.pathname}${href}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyRoomLink(roomId: string) {
|
||||||
|
const ok = await copyToClipboard(buildRoomUrl(roomId))
|
||||||
|
if (ok) message.success(t('common.copied'))
|
||||||
|
else message.error(t('common.copied') + ' ✗')
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomContextMenuOptions = computed<DropdownOption[]>(() => [
|
||||||
|
{ label: t('groupChat.copyRoomLink'), key: 'copy-link' },
|
||||||
|
{ label: t('groupChat.cloneRoom'), key: 'clone-room' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function handleRoomContextMenu(event: MouseEvent, roomId: string) {
|
||||||
|
event.preventDefault()
|
||||||
|
contextRoomId.value = roomId
|
||||||
|
roomContextMenuX.value = event.clientX
|
||||||
|
roomContextMenuY.value = event.clientY
|
||||||
|
showRoomContextMenu.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRoomContextClickOutside() {
|
||||||
|
showRoomContextMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRoomContextSelect(key: string) {
|
||||||
|
showRoomContextMenu.value = false
|
||||||
|
const roomId = contextRoomId.value
|
||||||
|
if (!roomId) return
|
||||||
|
if (key === 'copy-link') {
|
||||||
|
void copyRoomLink(roomId)
|
||||||
|
} else if (key === 'clone-room') {
|
||||||
|
handleOpenCloneRoom(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpenCloneRoom(roomId: string) {
|
function handleOpenCloneRoom(roomId: string) {
|
||||||
const room = store.rooms.find(r => r.id === roomId)
|
const room = store.rooms.find(r => r.id === roomId)
|
||||||
cloneSourceRoomId.value = roomId
|
cloneSourceRoomId.value = roomId
|
||||||
@@ -128,7 +177,7 @@ async function confirmCloneRoom() {
|
|||||||
cloneSourceRoomId.value = null
|
cloneSourceRoomId.value = null
|
||||||
cloneRoomName.value = ''
|
cloneRoomName.value = ''
|
||||||
cloneInviteCode.value = ''
|
cloneInviteCode.value = ''
|
||||||
await store.joinRoom(res.room.id)
|
await router.push({ name: 'hermes.groupChatRoom', params: { roomId: res.room.id } })
|
||||||
const failureMessage = formatAgentFailures(res.agentResults)
|
const failureMessage = formatAgentFailures(res.agentResults)
|
||||||
if (failureMessage) message.warning(failureMessage)
|
if (failureMessage) message.warning(failureMessage)
|
||||||
else message.success(t('groupChat.roomCloned'))
|
else message.success(t('groupChat.roomCloned'))
|
||||||
@@ -153,7 +202,7 @@ async function handleClearRoomContext() {
|
|||||||
|
|
||||||
async function handleSelectRoom(roomId: string) {
|
async function handleSelectRoom(roomId: string) {
|
||||||
try {
|
try {
|
||||||
await store.joinRoom(roomId)
|
await router.push({ name: 'hermes.groupChatRoom', params: { roomId } })
|
||||||
if (window.innerWidth <= 768) showSidebar.value = false
|
if (window.innerWidth <= 768) showSidebar.value = false
|
||||||
} catch {
|
} catch {
|
||||||
message.error(t('groupChat.joinFailed'))
|
message.error(t('groupChat.joinFailed'))
|
||||||
@@ -299,6 +348,7 @@ watch(() => store.sortedMessages.length, async () => {
|
|||||||
class="room-item"
|
class="room-item"
|
||||||
:class="{ active: store.currentRoomId === room.id }"
|
:class="{ active: store.currentRoomId === room.id }"
|
||||||
@click="handleSelectRoom(room.id)"
|
@click="handleSelectRoom(room.id)"
|
||||||
|
@contextmenu="handleRoomContextMenu($event, room.id)"
|
||||||
>
|
>
|
||||||
<svg class="room-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="room-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
@@ -308,11 +358,6 @@ watch(() => store.sortedMessages.length, async () => {
|
|||||||
<span v-if="room.inviteCode" class="room-code">{{ room.inviteCode }}</span>
|
<span v-if="room.inviteCode" class="room-code">{{ room.inviteCode }}</span>
|
||||||
<span class="room-tokens">{{ formatTokens(room.totalTokens || 0) }}</span>
|
<span class="room-tokens">{{ formatTokens(room.totalTokens || 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="room-action-btn" :title="t('groupChat.cloneRoom')" @click.stop="handleOpenCloneRoom(room.id)">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="8" y="8" width="12" height="12" rx="2" /><path d="M4 16V6a2 2 0 0 1 2-2h10" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<NPopconfirm @positive-click="handleDeleteRoom(room.id)">
|
<NPopconfirm @positive-click="handleDeleteRoom(room.id)">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<button class="room-action-btn danger" @click.stop>
|
<button class="room-action-btn danger" @click.stop>
|
||||||
@@ -328,6 +373,17 @@ watch(() => store.sortedMessages.length, async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NDropdown
|
||||||
|
placement="bottom-start"
|
||||||
|
trigger="manual"
|
||||||
|
:x="roomContextMenuX"
|
||||||
|
:y="roomContextMenuY"
|
||||||
|
:options="roomContextMenuOptions"
|
||||||
|
:show="showRoomContextMenu"
|
||||||
|
@select="handleRoomContextSelect"
|
||||||
|
@clickoutside="handleRoomContextClickOutside"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Main chat area -->
|
<!-- Main chat area -->
|
||||||
<div class="chat-main">
|
<div class="chat-main">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const { openSessionSearch } = useSessionSearch();
|
const { openSessionSearch } = useSessionSearch();
|
||||||
const selectedKey = computed(() => route.name as string);
|
const selectedKey = computed(() => {
|
||||||
|
if (route.name === "hermes.session") return "hermes.chat";
|
||||||
|
if (route.name === "hermes.historySession") return "hermes.history";
|
||||||
|
if (route.name === "hermes.groupChatRoom") return "hermes.groupChat";
|
||||||
|
return route.name as string;
|
||||||
|
});
|
||||||
const isSuperAdmin = computed(() => isStoredSuperAdmin());
|
const isSuperAdmin = computed(() => isStoredSuperAdmin());
|
||||||
const logoPath = '/logo.png';
|
const logoPath = '/logo.png';
|
||||||
|
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export default {
|
|||||||
noVisibleMessages: 'Keine für Menschen sichtbaren Nachrichten.',
|
noVisibleMessages: 'Keine für Menschen sichtbaren Nachrichten.',
|
||||||
monitorRoleUser: 'Benutzer',
|
monitorRoleUser: 'Benutzer',
|
||||||
monitorRoleAssistant: 'Assistent',
|
monitorRoleAssistant: 'Assistent',
|
||||||
|
copySessionLink: 'Sitzungslink kopieren',
|
||||||
copySessionId: 'Sitzungs-ID kopieren',
|
copySessionId: 'Sitzungs-ID kopieren',
|
||||||
export: 'Exportieren',
|
export: 'Exportieren',
|
||||||
exportFull: 'Vollständiger Export (JSON)',
|
exportFull: 'Vollständiger Export (JSON)',
|
||||||
@@ -1194,6 +1195,7 @@ jobTriggered: 'Job ausgelost',
|
|||||||
roomDeleted: 'Raum gelöscht',
|
roomDeleted: 'Raum gelöscht',
|
||||||
roomCloned: 'Raum geklont',
|
roomCloned: 'Raum geklont',
|
||||||
cloneRoom: 'Raum klonen',
|
cloneRoom: 'Raum klonen',
|
||||||
|
copyRoomLink: 'Raumlink kopieren',
|
||||||
deleteRoomConfirm: 'Diesen Raum löschen?',
|
deleteRoomConfirm: 'Diesen Raum löschen?',
|
||||||
clearContext: 'Kontext löschen',
|
clearContext: 'Kontext löschen',
|
||||||
clearContextConfirm: 'Diesen Raumkontext löschen? Nachrichten und Komprimierungs-Snapshots werden entfernt, Agenten und Mitglieder bleiben.',
|
clearContextConfirm: 'Diesen Raumkontext löschen? Nachrichten und Komprimierungs-Snapshots werden entfernt, Agenten und Mitglieder bleiben.',
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ export default {
|
|||||||
noVisibleMessages: 'No human-visible messages.',
|
noVisibleMessages: 'No human-visible messages.',
|
||||||
monitorRoleUser: 'User',
|
monitorRoleUser: 'User',
|
||||||
monitorRoleAssistant: 'Assistant',
|
monitorRoleAssistant: 'Assistant',
|
||||||
|
copySessionLink: 'Copy Session Link',
|
||||||
copySessionId: 'Copy Session ID',
|
copySessionId: 'Copy Session ID',
|
||||||
export: 'Export',
|
export: 'Export',
|
||||||
exportFull: 'Full Export (JSON)',
|
exportFull: 'Full Export (JSON)',
|
||||||
@@ -1127,6 +1128,7 @@ export default {
|
|||||||
roomDeleted: 'Room deleted',
|
roomDeleted: 'Room deleted',
|
||||||
roomCloned: 'Room cloned',
|
roomCloned: 'Room cloned',
|
||||||
cloneRoom: 'Clone room',
|
cloneRoom: 'Clone room',
|
||||||
|
copyRoomLink: 'Copy Room Link',
|
||||||
deleteRoomConfirm: 'Delete this room?',
|
deleteRoomConfirm: 'Delete this room?',
|
||||||
clearContext: 'Clear context',
|
clearContext: 'Clear context',
|
||||||
clearContextConfirm: 'Clear this room context? Messages and compression snapshots will be removed, but agents and members stay.',
|
clearContextConfirm: 'Clear this room context? Messages and compression snapshots will be removed, but agents and members stay.',
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export default {
|
|||||||
noVisibleMessages: 'No hay mensajes visibles para humanos.',
|
noVisibleMessages: 'No hay mensajes visibles para humanos.',
|
||||||
monitorRoleUser: 'Usuario',
|
monitorRoleUser: 'Usuario',
|
||||||
monitorRoleAssistant: 'Asistente',
|
monitorRoleAssistant: 'Asistente',
|
||||||
|
copySessionLink: 'Copiar enlace de sesión',
|
||||||
copySessionId: 'Copiar ID de sesión',
|
copySessionId: 'Copiar ID de sesión',
|
||||||
export: 'Exportar',
|
export: 'Exportar',
|
||||||
exportFull: 'Exportación completa (JSON)',
|
exportFull: 'Exportación completa (JSON)',
|
||||||
@@ -1194,6 +1195,7 @@ jobTriggered: 'Job ejecutado',
|
|||||||
roomDeleted: 'Sala eliminada',
|
roomDeleted: 'Sala eliminada',
|
||||||
roomCloned: 'Sala clonada',
|
roomCloned: 'Sala clonada',
|
||||||
cloneRoom: 'Clonar sala',
|
cloneRoom: 'Clonar sala',
|
||||||
|
copyRoomLink: 'Copiar enlace de sala',
|
||||||
deleteRoomConfirm: '¿Eliminar esta sala?',
|
deleteRoomConfirm: '¿Eliminar esta sala?',
|
||||||
clearContext: 'Limpiar contexto',
|
clearContext: 'Limpiar contexto',
|
||||||
clearContextConfirm: '¿Limpiar el contexto de esta sala? Se eliminarán mensajes e instantáneas de compresión, pero se conservan agentes y miembros.',
|
clearContextConfirm: '¿Limpiar el contexto de esta sala? Se eliminarán mensajes e instantáneas de compresión, pero se conservan agentes y miembros.',
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export default {
|
|||||||
noVisibleMessages: 'Aucun message visible par l’humain.',
|
noVisibleMessages: 'Aucun message visible par l’humain.',
|
||||||
monitorRoleUser: 'Utilisateur',
|
monitorRoleUser: 'Utilisateur',
|
||||||
monitorRoleAssistant: 'Assistant',
|
monitorRoleAssistant: 'Assistant',
|
||||||
|
copySessionLink: 'Copier le lien de session',
|
||||||
copySessionId: "Copier l'ID de session",
|
copySessionId: "Copier l'ID de session",
|
||||||
export: 'Exporter',
|
export: 'Exporter',
|
||||||
exportFull: 'Export complet (JSON)',
|
exportFull: 'Export complet (JSON)',
|
||||||
@@ -1194,6 +1195,7 @@ jobTriggered: 'Job declenche',
|
|||||||
roomDeleted: 'Salon supprime',
|
roomDeleted: 'Salon supprime',
|
||||||
roomCloned: 'Salon clone',
|
roomCloned: 'Salon clone',
|
||||||
cloneRoom: 'Cloner le salon',
|
cloneRoom: 'Cloner le salon',
|
||||||
|
copyRoomLink: 'Copier le lien du salon',
|
||||||
deleteRoomConfirm: 'Supprimer ce salon ?',
|
deleteRoomConfirm: 'Supprimer ce salon ?',
|
||||||
clearContext: 'Effacer le contexte',
|
clearContext: 'Effacer le contexte',
|
||||||
clearContextConfirm: 'Effacer le contexte de ce salon ? Les messages et instantanés de compression seront supprimés, les agents et membres restent.',
|
clearContextConfirm: 'Effacer le contexte de ce salon ? Les messages et instantanés de compression seront supprimés, les agents et membres restent.',
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export default {
|
|||||||
noVisibleMessages: '人間向けに表示できるメッセージはありません。',
|
noVisibleMessages: '人間向けに表示できるメッセージはありません。',
|
||||||
monitorRoleUser: 'ユーザー',
|
monitorRoleUser: 'ユーザー',
|
||||||
monitorRoleAssistant: 'アシスタント',
|
monitorRoleAssistant: 'アシスタント',
|
||||||
|
copySessionLink: 'セッションリンクをコピー',
|
||||||
copySessionId: 'セッション ID をコピー',
|
copySessionId: 'セッション ID をコピー',
|
||||||
export: 'エクスポート',
|
export: 'エクスポート',
|
||||||
exportFull: 'フルエクスポート (JSON)',
|
exportFull: 'フルエクスポート (JSON)',
|
||||||
@@ -1194,6 +1195,7 @@ export default {
|
|||||||
roomDeleted: 'ルームを削除しました',
|
roomDeleted: 'ルームを削除しました',
|
||||||
roomCloned: 'ルームを複製しました',
|
roomCloned: 'ルームを複製しました',
|
||||||
cloneRoom: 'ルームを複製',
|
cloneRoom: 'ルームを複製',
|
||||||
|
copyRoomLink: 'ルームリンクをコピー',
|
||||||
deleteRoomConfirm: 'このルームを削除しますか?',
|
deleteRoomConfirm: 'このルームを削除しますか?',
|
||||||
clearContext: 'コンテキストを削除',
|
clearContext: 'コンテキストを削除',
|
||||||
clearContextConfirm: 'このルームのコンテキストを削除しますか?メッセージと圧縮スナップショットは削除されますが、エージェントとメンバーは残ります。',
|
clearContextConfirm: 'このルームのコンテキストを削除しますか?メッセージと圧縮スナップショットは削除されますが、エージェントとメンバーは残ります。',
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export default {
|
|||||||
noVisibleMessages: '사람이 볼 수 있는 메시지가 없습니다.',
|
noVisibleMessages: '사람이 볼 수 있는 메시지가 없습니다.',
|
||||||
monitorRoleUser: '사용자',
|
monitorRoleUser: '사용자',
|
||||||
monitorRoleAssistant: '어시스턴트',
|
monitorRoleAssistant: '어시스턴트',
|
||||||
|
copySessionLink: '세션 링크 복사',
|
||||||
copySessionId: '세션 ID 복사',
|
copySessionId: '세션 ID 복사',
|
||||||
export: '내보내기',
|
export: '내보내기',
|
||||||
exportFull: '전체 내보내기 (JSON)',
|
exportFull: '전체 내보내기 (JSON)',
|
||||||
@@ -1194,6 +1195,7 @@ export default {
|
|||||||
roomDeleted: '방이 삭제되었습니다',
|
roomDeleted: '방이 삭제되었습니다',
|
||||||
roomCloned: '방이 복제되었습니다',
|
roomCloned: '방이 복제되었습니다',
|
||||||
cloneRoom: '방 복제',
|
cloneRoom: '방 복제',
|
||||||
|
copyRoomLink: '방 링크 복사',
|
||||||
deleteRoomConfirm: '이 방을 삭제하시겠습니까?',
|
deleteRoomConfirm: '이 방을 삭제하시겠습니까?',
|
||||||
clearContext: '컨텍스트 지우기',
|
clearContext: '컨텍스트 지우기',
|
||||||
clearContextConfirm: '이 방의 컨텍스트를 지우시겠습니까? 메시지와 압축 스냅샷은 삭제되고 에이전트와 멤버는 유지됩니다.',
|
clearContextConfirm: '이 방의 컨텍스트를 지우시겠습니까? 메시지와 압축 스냅샷은 삭제되고 에이전트와 멤버는 유지됩니다.',
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export default {
|
|||||||
noVisibleMessages: 'Nenhuma mensagem visível para humanos.',
|
noVisibleMessages: 'Nenhuma mensagem visível para humanos.',
|
||||||
monitorRoleUser: 'Usuário',
|
monitorRoleUser: 'Usuário',
|
||||||
monitorRoleAssistant: 'Assistente',
|
monitorRoleAssistant: 'Assistente',
|
||||||
|
copySessionLink: 'Copiar link da sessão',
|
||||||
copySessionId: 'Copiar ID da sessão',
|
copySessionId: 'Copiar ID da sessão',
|
||||||
export: 'Exportar',
|
export: 'Exportar',
|
||||||
exportFull: 'Exportação completa (JSON)',
|
exportFull: 'Exportação completa (JSON)',
|
||||||
@@ -1194,6 +1195,7 @@ jobTriggered: 'Job acionado',
|
|||||||
roomDeleted: 'Sala excluída',
|
roomDeleted: 'Sala excluída',
|
||||||
roomCloned: 'Sala clonada',
|
roomCloned: 'Sala clonada',
|
||||||
cloneRoom: 'Clonar sala',
|
cloneRoom: 'Clonar sala',
|
||||||
|
copyRoomLink: 'Copiar link da sala',
|
||||||
deleteRoomConfirm: 'Excluir esta sala?',
|
deleteRoomConfirm: 'Excluir esta sala?',
|
||||||
clearContext: 'Limpar contexto',
|
clearContext: 'Limpar contexto',
|
||||||
clearContextConfirm: 'Limpar o contexto desta sala? Mensagens e snapshots de compactação serão removidos, mas agentes e membros ficam.',
|
clearContextConfirm: 'Limpar o contexto desta sala? Mensagens e snapshots de compactação serão removidos, mas agentes e membros ficam.',
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ export default {
|
|||||||
noVisibleMessages: '沒有人類可見訊息。',
|
noVisibleMessages: '沒有人類可見訊息。',
|
||||||
monitorRoleUser: '使用者',
|
monitorRoleUser: '使用者',
|
||||||
monitorRoleAssistant: '助手',
|
monitorRoleAssistant: '助手',
|
||||||
|
copySessionLink: '複製工作階段連結',
|
||||||
copySessionId: '複製工作階段 ID',
|
copySessionId: '複製工作階段 ID',
|
||||||
export: '匯出',
|
export: '匯出',
|
||||||
exportFull: '完整匯出 (JSON)',
|
exportFull: '完整匯出 (JSON)',
|
||||||
@@ -1130,6 +1131,7 @@ export default {
|
|||||||
roomDeleted: '房間已刪除',
|
roomDeleted: '房間已刪除',
|
||||||
roomCloned: '房間已複製',
|
roomCloned: '房間已複製',
|
||||||
cloneRoom: '複製房間',
|
cloneRoom: '複製房間',
|
||||||
|
copyRoomLink: '複製房間連結',
|
||||||
deleteRoomConfirm: '確定刪除這個房間嗎?',
|
deleteRoomConfirm: '確定刪除這個房間嗎?',
|
||||||
clearContext: '清理上下文',
|
clearContext: '清理上下文',
|
||||||
clearContextConfirm: '確定清理目前房間上下文嗎?訊息和壓縮快照會被刪除,智慧代理和成員會保留。',
|
clearContextConfirm: '確定清理目前房間上下文嗎?訊息和壓縮快照會被刪除,智慧代理和成員會保留。',
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ export default {
|
|||||||
noVisibleMessages: '没有人类可见消息。',
|
noVisibleMessages: '没有人类可见消息。',
|
||||||
monitorRoleUser: '用户',
|
monitorRoleUser: '用户',
|
||||||
monitorRoleAssistant: '助手',
|
monitorRoleAssistant: '助手',
|
||||||
|
copySessionLink: '复制会话链接',
|
||||||
copySessionId: '复制会话 ID',
|
copySessionId: '复制会话 ID',
|
||||||
export: '导出',
|
export: '导出',
|
||||||
exportFull: '全量导出 (JSON)',
|
exportFull: '全量导出 (JSON)',
|
||||||
@@ -1129,6 +1130,7 @@ export default {
|
|||||||
roomDeleted: '房间已删除',
|
roomDeleted: '房间已删除',
|
||||||
roomCloned: '房间已克隆',
|
roomCloned: '房间已克隆',
|
||||||
cloneRoom: '克隆房间',
|
cloneRoom: '克隆房间',
|
||||||
|
copyRoomLink: '复制房间链接',
|
||||||
deleteRoomConfirm: '确定删除这个房间吗?',
|
deleteRoomConfirm: '确定删除这个房间吗?',
|
||||||
clearContext: '清理上下文',
|
clearContext: '清理上下文',
|
||||||
clearContextConfirm: '确定清理当前房间上下文吗?消息和压缩快照会被删除,智能体和成员会保留。',
|
clearContextConfirm: '确定清理当前房间上下文吗?消息和压缩快照会被删除,智能体和成员会保留。',
|
||||||
|
|||||||
@@ -15,11 +15,21 @@ const router = createRouter({
|
|||||||
name: 'hermes.chat',
|
name: 'hermes.chat',
|
||||||
component: () => import('@/views/hermes/ChatView.vue'),
|
component: () => import('@/views/hermes/ChatView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/hermes/session/:sessionId',
|
||||||
|
name: 'hermes.session',
|
||||||
|
component: () => import('@/views/hermes/ChatView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/hermes/history',
|
path: '/hermes/history',
|
||||||
name: 'hermes.history',
|
name: 'hermes.history',
|
||||||
component: () => import('@/views/hermes/HistoryView.vue'),
|
component: () => import('@/views/hermes/HistoryView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/hermes/history/session/:sessionId',
|
||||||
|
name: 'hermes.historySession',
|
||||||
|
component: () => import('@/views/hermes/HistoryView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/hermes/jobs',
|
path: '/hermes/jobs',
|
||||||
name: 'hermes.jobs',
|
name: 'hermes.jobs',
|
||||||
@@ -97,6 +107,11 @@ const router = createRouter({
|
|||||||
name: 'hermes.groupChat',
|
name: 'hermes.groupChat',
|
||||||
component: () => import('@/views/hermes/GroupChatView.vue'),
|
component: () => import('@/views/hermes/GroupChatView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/hermes/group-chat/room/:roomId',
|
||||||
|
name: 'hermes.groupChatRoom',
|
||||||
|
component: () => import('@/views/hermes/GroupChatView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/hermes/files',
|
path: '/hermes/files',
|
||||||
name: 'hermes.files',
|
name: 'hermes.files',
|
||||||
|
|||||||
@@ -336,6 +336,14 @@ function setItemBestEffort(key: string, value: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getItemBestEffort(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function removeItem(key: string) {
|
function removeItem(key: string) {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(key)
|
localStorage.removeItem(key)
|
||||||
@@ -422,7 +430,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
removeItem(storageKey())
|
removeItem(storageKey())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSessions(profile?: string | null) {
|
async function loadSessions(profile?: string | null, preferredSessionId?: string | null) {
|
||||||
isLoadingSessions.value = true
|
isLoadingSessions.value = true
|
||||||
try {
|
try {
|
||||||
const list = await fetchSessions(undefined, undefined, profile || undefined)
|
const list = await fetchSessions(undefined, undefined, profile || undefined)
|
||||||
@@ -436,10 +444,18 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
sessions.value = fresh
|
sessions.value = fresh
|
||||||
|
|
||||||
// Restore last active session, fallback to most recent
|
// Restore route-selected session first (tab-local source of truth),
|
||||||
const savedId = activeSessionId.value
|
// then current in-memory session, then persisted legacy/default choice,
|
||||||
const targetId = savedId && sessions.value.some(s => s.id === savedId)
|
// then fallback to the most recent session.
|
||||||
? savedId
|
const currentId = activeSessionId.value
|
||||||
|
const legacyActiveKey = legacyStorageKey()
|
||||||
|
const storedId = getItemBestEffort(storageKey()) || (legacyActiveKey ? getItemBestEffort(LEGACY_STORAGE_KEY) : null)
|
||||||
|
const targetId = preferredSessionId && sessions.value.some(s => s.id === preferredSessionId)
|
||||||
|
? preferredSessionId
|
||||||
|
: currentId && sessions.value.some(s => s.id === currentId)
|
||||||
|
? currentId
|
||||||
|
: storedId && sessions.value.some(s => s.id === storedId)
|
||||||
|
? storedId
|
||||||
: sessions.value[0]?.id
|
: sessions.value[0]?.id
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
await switchSession(targetId)
|
await switchSession(targetId)
|
||||||
@@ -459,7 +475,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const sid = activeSessionId.value
|
const sid = activeSessionId.value
|
||||||
if (!sid) return false
|
if (!sid) return false
|
||||||
try {
|
try {
|
||||||
const detail = await fetchSession(sid)
|
const detail = await fetchSession(sid, activeSession.value?.profile)
|
||||||
if (!detail) return false
|
if (!detail) return false
|
||||||
const target = sessions.value.find(s => s.id === sid)
|
const target = sessions.value.find(s => s.id === sid)
|
||||||
if (!target) return false
|
if (!target) return false
|
||||||
@@ -533,6 +549,15 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const timeout = setTimeout(() => reject(new Error('resume timeout')), 15_000)
|
const timeout = setTimeout(() => reject(new Error('resume timeout')), 15_000)
|
||||||
resumeSession(sessionId, (data) => {
|
resumeSession(sessionId, (data) => {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
|
if (data.session_id !== sessionId || activeSessionId.value !== sessionId) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = sessions.value.find(s => s.id === sessionId)
|
||||||
|
if (!target) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (data.isWorking) {
|
if (data.isWorking) {
|
||||||
serverWorking.value.add(sessionId)
|
serverWorking.value.add(sessionId)
|
||||||
} else {
|
} else {
|
||||||
@@ -553,19 +578,20 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
} else if (!data.isWorking) {
|
} else if (!data.isWorking) {
|
||||||
setAbortState(null)
|
setAbortState(null)
|
||||||
}
|
}
|
||||||
if (data.inputTokens != null) activeSession.value!.inputTokens = data.inputTokens
|
if (data.inputTokens != null) target.inputTokens = data.inputTokens
|
||||||
if (data.outputTokens != null) activeSession.value!.outputTokens = data.outputTokens
|
if (data.outputTokens != null) target.outputTokens = data.outputTokens
|
||||||
if ((data as any).contextTokens != null) activeSession.value!.contextTokens = (data as any).contextTokens
|
if ((data as any).contextTokens != null) target.contextTokens = (data as any).contextTokens
|
||||||
if (data.messages?.length) {
|
if (data.messages?.length) {
|
||||||
activeSession.value!.messages = mapHermesMessages(data.messages as any[])
|
target.messages = mapHermesMessages(data.messages as any[])
|
||||||
}
|
}
|
||||||
if (!activeSession.value!.title) {
|
if (!target.title) {
|
||||||
const firstUser = activeSession.value!.messages.find(m => m.role === 'user')
|
const firstUser = target.messages.find(m => m.role === 'user')
|
||||||
if (firstUser) {
|
if (firstUser) {
|
||||||
const t = firstUser.content.slice(0, 40)
|
const t = firstUser.content.slice(0, 40)
|
||||||
activeSession.value!.title = t + (firstUser.content.length > 40 ? '...' : '')
|
target.title = t + (firstUser.content.length > 40 ? '...' : '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
activeSession.value = target
|
||||||
// Process replayed events (compression state etc.)
|
// Process replayed events (compression state etc.)
|
||||||
if (data.events?.length) {
|
if (data.events?.length) {
|
||||||
for (const evt of data.events) {
|
for (const evt of data.events) {
|
||||||
@@ -588,7 +614,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
compressed: e.compressed ?? false,
|
compressed: e.compressed ?? false,
|
||||||
error: e.error,
|
error: e.error,
|
||||||
})
|
})
|
||||||
if (e.contextTokens != null) activeSession.value!.contextTokens = e.contextTokens
|
if (e.contextTokens != null) target.contextTokens = e.contextTokens
|
||||||
} else if (e.event === 'abort.started') {
|
} else if (e.event === 'abort.started') {
|
||||||
setAbortState({ aborting: true, synced: null })
|
setAbortState({ aborting: true, synced: null })
|
||||||
} else if (e.event === 'abort.completed') {
|
} else if (e.event === 'abort.completed') {
|
||||||
@@ -645,7 +671,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolve()
|
resolve()
|
||||||
})
|
}, activeSession.value?.profile)
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load session messages via resume:', err)
|
console.error('Failed to load session messages via resume:', err)
|
||||||
@@ -654,17 +680,20 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resume in-flight run event listeners if needed
|
// Resume in-flight run event listeners if needed
|
||||||
|
if (activeSessionId.value === sessionId) {
|
||||||
resumeServerWorkingRun(sessionId)
|
resumeServerWorkingRun(sessionId)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function newChat(options: { profile?: string; model?: string; provider?: string } = {}) {
|
function newChat(options: { profile?: string; model?: string; provider?: string } = {}): Session {
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
profile: options.profile,
|
profile: options.profile,
|
||||||
model: options.model || appStore.selectedModel || undefined,
|
model: options.model || appStore.selectedModel || undefined,
|
||||||
provider: options.provider || appStore.selectedProvider || '',
|
provider: options.provider || appStore.selectedProvider || '',
|
||||||
})
|
})
|
||||||
switchSession(session.id)
|
void switchSession(session.id)
|
||||||
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchSessionModel(modelId: string, provider?: string, sessionId?: string): Promise<boolean> {
|
async function switchSessionModel(modelId: string, provider?: string, sessionId?: string): Promise<boolean> {
|
||||||
@@ -685,7 +714,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSession(sessionId: string) {
|
async function deleteSession(sessionId: string) {
|
||||||
await deleteSessionApi(sessionId)
|
const target = sessions.value.find(s => s.id === sessionId)
|
||||||
|
await deleteSessionApi(sessionId, target?.profile)
|
||||||
sessions.value = sessions.value.filter(s => s.id !== sessionId)
|
sessions.value = sessions.value.filter(s => s.id !== sessionId)
|
||||||
if (activeSessionId.value === sessionId) {
|
if (activeSessionId.value === sessionId) {
|
||||||
if (sessions.value.length > 0) {
|
if (sessions.value.length > 0) {
|
||||||
@@ -1078,7 +1108,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const runPayload = {
|
const runPayload = {
|
||||||
input,
|
input,
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
profile: shouldSendInitialSessionConfig ? activeSession.value?.profile || undefined : undefined,
|
profile: activeSession.value?.profile || useProfilesStore().activeProfileName || undefined,
|
||||||
model: shouldSendInitialSessionConfig ? sessionModel || undefined : undefined,
|
model: shouldSendInitialSessionConfig ? sessionModel || undefined : undefined,
|
||||||
provider: shouldSendInitialSessionConfig ? sessionProvider || undefined : undefined,
|
provider: shouldSendInitialSessionConfig ? sessionProvider || undefined : undefined,
|
||||||
model_groups: appStore.modelGroups.map(group => ({
|
model_groups: appStore.modelGroups.map(group => ({
|
||||||
@@ -2054,7 +2084,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
activeSession.value.messages = mapHermesMessages(data.messages as any[])
|
activeSession.value.messages = mapHermesMessages(data.messages as any[])
|
||||||
}
|
}
|
||||||
resumeServerWorkingRun(sid)
|
resumeServerWorkingRun(sid)
|
||||||
})
|
}, activeSession.value?.profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
|
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
|
||||||
import { useAppStore } from '@/stores/hermes/app'
|
import { useAppStore } from '@/stores/hermes/app'
|
||||||
import { useChatStore } from '@/stores/hermes/chat'
|
import { useChatStore } from '@/stores/hermes/chat'
|
||||||
@@ -10,6 +11,25 @@ const appStore = useAppStore()
|
|||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const profilesStore = useProfilesStore()
|
const profilesStore = useProfilesStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const routeSessionId = computed(() => {
|
||||||
|
const value = route.params.sessionId
|
||||||
|
return typeof value === 'string' && value.trim() ? value : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeProfile = computed(() => {
|
||||||
|
const value = route.query.profile
|
||||||
|
return typeof value === 'string' && value.trim() ? value : null
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadRouteSession() {
|
||||||
|
await chatStore.loadSessions(routeProfile.value, routeSessionId.value)
|
||||||
|
if (routeSessionId.value && chatStore.activeSessionId !== routeSessionId.value) {
|
||||||
|
await router.replace({ name: 'hermes.chat' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
appStore.loadModels()
|
appStore.loadModels()
|
||||||
@@ -19,7 +39,29 @@ onMounted(async () => {
|
|||||||
profilesStore.fetchProfiles(),
|
profilesStore.fetchProfiles(),
|
||||||
settingsStore.fetchSettings(),
|
settingsStore.fetchSettings(),
|
||||||
])
|
])
|
||||||
chatStore.loadSessions()
|
await loadRouteSession()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([routeSessionId, routeProfile], async ([sessionId]) => {
|
||||||
|
if (!chatStore.sessionsLoaded) return
|
||||||
|
if (!sessionId) {
|
||||||
|
await chatStore.loadSessions(routeProfile.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (chatStore.activeSessionId === sessionId && (!routeProfile.value || chatStore.activeSession?.profile === routeProfile.value)) return
|
||||||
|
|
||||||
|
if (routeProfile.value) {
|
||||||
|
await loadRouteSession()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = chatStore.sessions.some(session => session.id === sessionId)
|
||||||
|
if (!exists) {
|
||||||
|
await router.replace({ name: 'hermes.chat' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await chatStore.switchSession(sessionId)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted } from 'vue'
|
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import GroupChatPanel from '@/components/hermes/group-chat/GroupChatPanel.vue'
|
import GroupChatPanel from '@/components/hermes/group-chat/GroupChatPanel.vue'
|
||||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||||
|
|
||||||
const store = useGroupChatStore()
|
const store = useGroupChatStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
onMounted(() => {
|
const routeRoomId = computed(() => {
|
||||||
|
const value = route.params.roomId
|
||||||
|
return typeof value === 'string' && value.trim() ? value : null
|
||||||
|
})
|
||||||
|
|
||||||
|
async function syncRouteRoom() {
|
||||||
|
const roomId = routeRoomId.value
|
||||||
|
if (!roomId) return
|
||||||
|
|
||||||
|
if (!store.rooms.some(room => room.id === roomId)) {
|
||||||
|
await router.replace({ name: 'hermes.groupChat' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.currentRoomId !== roomId) {
|
||||||
|
await store.joinRoom(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
store.connect()
|
store.connect()
|
||||||
store.loadRooms()
|
await store.loadRooms()
|
||||||
|
await syncRouteRoom()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(routeRoomId, async (roomId) => {
|
||||||
|
if (!roomId) return
|
||||||
|
if (store.rooms.length === 0) return
|
||||||
|
await syncRouteRoom()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useChatStore, type Session } from '@/stores/hermes/chat'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { type Session } from '@/stores/hermes/chat'
|
||||||
import { useAppStore } from '@/stores/hermes/app'
|
import { useAppStore } from '@/stores/hermes/app'
|
||||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||||
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
||||||
@@ -13,12 +14,23 @@ import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
|
|||||||
import OutlinePanel from '@/components/hermes/chat/OutlinePanel.vue'
|
import OutlinePanel from '@/components/hermes/chat/OutlinePanel.vue'
|
||||||
import { deleteSession, fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions'
|
import { deleteSession, fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const profilesStore = useProfilesStore()
|
const profilesStore = useProfilesStore()
|
||||||
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore()
|
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const routeSessionId = computed(() => {
|
||||||
|
const value = route.params.sessionId
|
||||||
|
return typeof value === 'string' && value.trim() ? value : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeProfile = computed(() => {
|
||||||
|
const value = route.query.profile
|
||||||
|
return typeof value === 'string' && value.trim() ? value : null
|
||||||
|
})
|
||||||
|
|
||||||
// Hermes history sessions (exclude api_server)
|
// Hermes history sessions (exclude api_server)
|
||||||
const hermesSessions = ref<SessionSummary[]>([])
|
const hermesSessions = ref<SessionSummary[]>([])
|
||||||
@@ -33,7 +45,7 @@ async function loadHermesSessions() {
|
|||||||
if (hermesSessionsLoading.value) return
|
if (hermesSessionsLoading.value) return
|
||||||
hermesSessionsLoading.value = true
|
hermesSessionsLoading.value = true
|
||||||
try {
|
try {
|
||||||
hermesSessions.value = await fetchHermesSessions()
|
hermesSessions.value = await fetchHermesSessions(undefined, undefined, routeProfile.value)
|
||||||
hermesSessionsLoaded.value = true
|
hermesSessionsLoaded.value = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load Hermes sessions:', err)
|
console.error('Failed to load Hermes sessions:', err)
|
||||||
@@ -53,7 +65,7 @@ function findHistorySession(sessionId: string): SessionSummary | undefined {
|
|||||||
return hermesSessions.value.find(session => session.id === sessionId)
|
return hermesSessions.value.find(session => session.id === sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSessionClick(sessionId: string, profile?: string | null) {
|
async function loadHistorySession(sessionId: string, profile?: string | null) {
|
||||||
const summary = findHistorySession(sessionId)
|
const summary = findHistorySession(sessionId)
|
||||||
const sessionProfile = profile || summary?.profile || null
|
const sessionProfile = profile || summary?.profile || null
|
||||||
// First, fetch the Hermes session detail
|
// First, fetch the Hermes session detail
|
||||||
@@ -112,6 +124,30 @@ async function handleSessionClick(sessionId: string, profile?: string | null) {
|
|||||||
if (mobileQuery?.matches) showSessions.value = false
|
if (mobileQuery?.matches) showSessions.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSessionClick(sessionId: string, profile?: string | null) {
|
||||||
|
await router.push({
|
||||||
|
name: 'hermes.historySession',
|
||||||
|
params: { sessionId },
|
||||||
|
query: profile ? { profile } : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRouteSession() {
|
||||||
|
const sessionId = routeSessionId.value
|
||||||
|
if (!sessionId) return
|
||||||
|
|
||||||
|
if (!hermesSessions.value.some(s => s.id === sessionId)) {
|
||||||
|
historySessionId.value = null
|
||||||
|
historySession.value = null
|
||||||
|
await router.replace({ name: 'hermes.history' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historySessionId.value !== sessionId) {
|
||||||
|
await loadHistorySession(sessionId, routeProfile.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||||
isMobile.value = e.matches
|
isMobile.value = e.matches
|
||||||
if (e.matches && showSessions.value) {
|
if (e.matches && showSessions.value) {
|
||||||
@@ -123,6 +159,7 @@ onMounted(async () => {
|
|||||||
appStore.loadModels()
|
appStore.loadModels()
|
||||||
await profilesStore.fetchProfiles()
|
await profilesStore.fetchProfiles()
|
||||||
await loadHermesSessions()
|
await loadHermesSessions()
|
||||||
|
await syncRouteSession()
|
||||||
|
|
||||||
mobileQuery = window.matchMedia('(max-width: 768px)')
|
mobileQuery = window.matchMedia('(max-width: 768px)')
|
||||||
handleMobileChange(mobileQuery)
|
handleMobileChange(mobileQuery)
|
||||||
@@ -133,6 +170,20 @@ onUnmounted(() => {
|
|||||||
mobileQuery?.removeEventListener('change', handleMobileChange)
|
mobileQuery?.removeEventListener('change', handleMobileChange)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch([routeSessionId, routeProfile], async ([sessionId]) => {
|
||||||
|
if (!sessionId) {
|
||||||
|
historySessionId.value = null
|
||||||
|
historySession.value = null
|
||||||
|
if (hermesSessionsLoaded.value) await loadHermesSessions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hermesSessionsLoaded.value) return
|
||||||
|
if (routeProfile.value && !hermesSessions.value.some(s => s.profile === routeProfile.value)) {
|
||||||
|
await loadHermesSessions()
|
||||||
|
}
|
||||||
|
await syncRouteSession()
|
||||||
|
})
|
||||||
|
|
||||||
const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]')))
|
const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]')))
|
||||||
|
|
||||||
// Convert SessionSummary to Session format
|
// Convert SessionSummary to Session format
|
||||||
@@ -219,7 +270,7 @@ function toggleGroup(source: string) {
|
|||||||
const group = groupedSessions.value.find(g => g.source === source)
|
const group = groupedSessions.value.find(g => g.source === source)
|
||||||
if (group?.sessions.length) {
|
if (group?.sessions.length) {
|
||||||
// Auto-select and load first session when expanding group
|
// Auto-select and load first session when expanding group
|
||||||
handleSessionClick(group.sessions[0].id, group.sessions[0].profile)
|
void handleSessionClick(group.sessions[0].id, group.sessions[0].profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||||
@@ -227,7 +278,7 @@ function toggleGroup(source: string) {
|
|||||||
|
|
||||||
watch(groupedSessions, groups => {
|
watch(groupedSessions, groups => {
|
||||||
if (localStorage.getItem('hermes_collapsed_groups') !== null) {
|
if (localStorage.getItem('hermes_collapsed_groups') !== null) {
|
||||||
const activeSource = chatStore.activeSession?.source
|
const activeSource = historySession.value?.source
|
||||||
if (activeSource && collapsedGroups.value.has(activeSource)) {
|
if (activeSource && collapsedGroups.value.has(activeSource)) {
|
||||||
collapsedGroups.value = new Set([...collapsedGroups.value].filter(source => source !== activeSource))
|
collapsedGroups.value = new Set([...collapsedGroups.value].filter(source => source !== activeSource))
|
||||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||||
@@ -243,7 +294,7 @@ watch(groupedSessions, groups => {
|
|||||||
|
|
||||||
// Auto-load first CLI session when Hermes sessions are loaded
|
// Auto-load first CLI session when Hermes sessions are loaded
|
||||||
watch(hermesSessionsLoaded, (loaded) => {
|
watch(hermesSessionsLoaded, (loaded) => {
|
||||||
if (loaded && hermesSessions.value.length > 0) {
|
if (loaded && hermesSessions.value.length > 0 && !routeSessionId.value) {
|
||||||
// Only auto-load if no session is currently active
|
// Only auto-load if no session is currently active
|
||||||
if (!historySessionId.value || !hermesSessions.value.find(s => s.id === historySessionId.value)) {
|
if (!historySessionId.value || !hermesSessions.value.find(s => s.id === historySessionId.value)) {
|
||||||
// Find first CLI session.
|
// Find first CLI session.
|
||||||
@@ -254,7 +305,7 @@ watch(hermesSessionsLoaded, (loaded) => {
|
|||||||
collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== firstCliSession.source))
|
collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== firstCliSession.source))
|
||||||
}
|
}
|
||||||
// Load session details
|
// Load session details
|
||||||
handleSessionClick(firstCliSession.id, firstCliSession.profile)
|
void handleSessionClick(firstCliSession.id, firstCliSession.profile)
|
||||||
}
|
}
|
||||||
// If no CLI session exists, don't auto-load any session
|
// If no CLI session exists, don't auto-load any session
|
||||||
}
|
}
|
||||||
@@ -278,6 +329,30 @@ async function copySessionId(id?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function historySessionProfile(sessionId: string): string | null {
|
||||||
|
return historySession.value?.id === sessionId
|
||||||
|
? historySession.value.profile || null
|
||||||
|
: findHistorySession(sessionId)?.profile || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHistorySessionUrl(sessionId: string, profile?: string | null) {
|
||||||
|
const href = router.resolve({
|
||||||
|
name: 'hermes.historySession',
|
||||||
|
params: { sessionId },
|
||||||
|
query: profile ? { profile } : undefined,
|
||||||
|
}).href
|
||||||
|
return `${window.location.origin}${window.location.pathname}${href}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySessionLink(id?: string) {
|
||||||
|
const sessionId = id || historySessionId.value
|
||||||
|
if (sessionId) {
|
||||||
|
const ok = await copyToClipboard(buildHistorySessionUrl(sessionId, historySessionProfile(sessionId)))
|
||||||
|
if (ok) message.success(t('common.copied'))
|
||||||
|
else message.error(t('common.copied') + ' ✗')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteSession(id: string, profile?: string | null) {
|
async function handleDeleteSession(id: string, profile?: string | null) {
|
||||||
const summary = findHistorySession(id)
|
const summary = findHistorySession(id)
|
||||||
const sessionProfile = profile || summary?.profile || null
|
const sessionProfile = profile || summary?.profile || null
|
||||||
@@ -295,6 +370,7 @@ async function handleDeleteSession(id: string, profile?: string | null) {
|
|||||||
historySession.value = null
|
historySession.value = null
|
||||||
const next = historySessions.value[0]
|
const next = historySessions.value[0]
|
||||||
if (next) await handleSessionClick(next.id, next.profile)
|
if (next) await handleSessionClick(next.id, next.profile)
|
||||||
|
else await router.replace({ name: 'hermes.history' })
|
||||||
}
|
}
|
||||||
|
|
||||||
message.success(t('chat.sessionDeleted'))
|
message.success(t('chat.sessionDeleted'))
|
||||||
@@ -387,6 +463,16 @@ async function handleDeleteSession(id: string, profile?: string | null) {
|
|||||||
</template>
|
</template>
|
||||||
{{ t('chat.outlineTitle') }}
|
{{ t('chat.outlineTitle') }}
|
||||||
</NTooltip>
|
</NTooltip>
|
||||||
|
<NTooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton quaternary size="small" @click="copySessionLink()" circle>
|
||||||
|
<template #icon>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
{{ t('chat.copySessionLink') }}
|
||||||
|
</NTooltip>
|
||||||
<NTooltip trigger="hover">
|
<NTooltip trigger="hover">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<NButton quaternary size="small" @click="copySessionId()" circle>
|
<NButton quaternary size="small" @click="copySessionId()" circle>
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||||
|
|
||||||
|
const inputPlaceholder = 'Type a message... (Enter to send, Shift+Enter for new line)'
|
||||||
|
|
||||||
|
type SessionSeed = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
lastActive: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionSummary({ id, title, lastActive }: SessionSeed) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
profile: 'research',
|
||||||
|
source: 'cli',
|
||||||
|
model: 'test-model',
|
||||||
|
provider: 'test-provider',
|
||||||
|
title,
|
||||||
|
preview: title,
|
||||||
|
started_at: lastActive - 10,
|
||||||
|
ended_at: null,
|
||||||
|
last_active: lastActive,
|
||||||
|
message_count: 1,
|
||||||
|
tool_call_count: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
reasoning_tokens: 0,
|
||||||
|
billing_provider: null,
|
||||||
|
estimated_cost_usd: 0,
|
||||||
|
actual_cost_usd: null,
|
||||||
|
cost_status: 'estimated',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumePayload(sessionId: string, content: string) {
|
||||||
|
return {
|
||||||
|
session_id: sessionId,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
session_id: sessionId,
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
tool_call_id: null,
|
||||||
|
tool_calls: null,
|
||||||
|
tool_name: null,
|
||||||
|
token_count: null,
|
||||||
|
finish_reason: null,
|
||||||
|
reasoning: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isWorking: false,
|
||||||
|
events: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = [
|
||||||
|
sessionSummary({ id: 'session-a', title: 'Alpha chat', lastActive: 100 }),
|
||||||
|
sessionSummary({ id: 'session-b', title: 'Beta chat', lastActive: 200 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const resumes = {
|
||||||
|
'session-a': resumePayload('session-a', 'Alpha route content'),
|
||||||
|
'session-b': resumePayload('session-b', 'Beta route content'),
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupChatPage(page: Page) {
|
||||||
|
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||||
|
await page.addInitScript((payload) => {
|
||||||
|
;(window as any).__PW_CHAT_SOCKET_RESUMES__ = payload
|
||||||
|
window.localStorage.setItem('hermes_active_session_research', 'session-b')
|
||||||
|
}, resumes)
|
||||||
|
const api = await mockHermesApi(page, { sessions })
|
||||||
|
await mockChatSocket(page)
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChatMessage(page: Page, message: string) {
|
||||||
|
const input = page.getByPlaceholder(inputPlaceholder)
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill(message)
|
||||||
|
await page.getByRole('button', { name: 'Send' }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRun(page: Page, index = 0) {
|
||||||
|
const handle = await page.waitForFunction((runIndex) => {
|
||||||
|
const state = (window as any).__PW_CHAT_SOCKET__
|
||||||
|
const runs = state?.emitted?.filter((item: any) => item.event === 'run') || []
|
||||||
|
const run = runs[runIndex]
|
||||||
|
return run ? run.payload : null
|
||||||
|
}, index)
|
||||||
|
return handle.jsonValue() as Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
test('route session id wins over shared active-session localStorage', async ({ page }) => {
|
||||||
|
const api = await setupChatPage(page)
|
||||||
|
|
||||||
|
await page.goto('/#/hermes/session/session-a')
|
||||||
|
|
||||||
|
await expect(page.getByText('Alpha route content')).toBeVisible()
|
||||||
|
await expect(page.getByText('Beta route content')).toHaveCount(0)
|
||||||
|
await expect(page).toHaveURL(/#\/hermes\/session\/session-a$/)
|
||||||
|
expect(api.unexpectedRequests).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('two tabs can show different sessions and keep them after reload', async ({ context }) => {
|
||||||
|
const pageA = await context.newPage()
|
||||||
|
const pageB = await context.newPage()
|
||||||
|
const apiA = await setupChatPage(pageA)
|
||||||
|
const apiB = await setupChatPage(pageB)
|
||||||
|
|
||||||
|
await pageA.goto('/#/hermes/session/session-a')
|
||||||
|
await pageB.goto('/#/hermes/session/session-b')
|
||||||
|
|
||||||
|
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||||
|
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||||
|
|
||||||
|
await pageA.reload()
|
||||||
|
await pageB.reload()
|
||||||
|
|
||||||
|
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||||
|
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||||
|
await expect(pageA).toHaveURL(/#\/hermes\/session\/session-a$/)
|
||||||
|
await expect(pageB).toHaveURL(/#\/hermes\/session\/session-b$/)
|
||||||
|
expect(apiA.unexpectedRequests).toEqual([])
|
||||||
|
expect(apiB.unexpectedRequests).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parallel tabs send runs and render progress only for their own session', async ({ context }) => {
|
||||||
|
const pageA = await context.newPage()
|
||||||
|
const pageB = await context.newPage()
|
||||||
|
const apiA = await setupChatPage(pageA)
|
||||||
|
const apiB = await setupChatPage(pageB)
|
||||||
|
|
||||||
|
await pageA.goto('/#/hermes/session/session-a')
|
||||||
|
await pageB.goto('/#/hermes/session/session-b')
|
||||||
|
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||||
|
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||||
|
|
||||||
|
await sendChatMessage(pageA, 'Question for Alpha')
|
||||||
|
await sendChatMessage(pageB, 'Question for Beta')
|
||||||
|
|
||||||
|
const runA = await waitForRun(pageA)
|
||||||
|
const runB = await waitForRun(pageB)
|
||||||
|
expect(runA.session_id).toBe('session-a')
|
||||||
|
expect(runB.session_id).toBe('session-b')
|
||||||
|
|
||||||
|
await pageA.evaluate((sid) => {
|
||||||
|
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||||
|
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-a' })
|
||||||
|
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-a', delta: 'Alpha progress' })
|
||||||
|
}, runA.session_id)
|
||||||
|
await pageB.evaluate((sid) => {
|
||||||
|
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||||
|
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-b' })
|
||||||
|
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-b', delta: 'Beta progress' })
|
||||||
|
}, runB.session_id)
|
||||||
|
|
||||||
|
await expect(pageA.getByText('Alpha progress')).toBeVisible()
|
||||||
|
await expect(pageA.getByText('Beta progress')).toHaveCount(0)
|
||||||
|
await expect(pageB.getByText('Beta progress')).toBeVisible()
|
||||||
|
await expect(pageB.getByText('Alpha progress')).toHaveCount(0)
|
||||||
|
expect(apiA.unexpectedRequests).toEqual([])
|
||||||
|
expect(apiB.unexpectedRequests).toEqual([])
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test'
|
||||||
|
import { authenticate } from './fixtures'
|
||||||
|
|
||||||
|
const rooms = [
|
||||||
|
{ id: 'room-alpha', name: 'Alpha Room', inviteCode: 'ALPHA1', triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10, totalTokens: 123 },
|
||||||
|
{ id: 'room-beta', name: 'Beta Room', inviteCode: 'BETA22', triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10, totalTokens: 456 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const messagesByRoom: Record<string, unknown[]> = {
|
||||||
|
'room-alpha': [
|
||||||
|
{ id: 'alpha-msg', roomId: 'room-alpha', senderId: 'user-1', senderName: 'Alice', content: 'Alpha room message', timestamp: 1_790_000_000, role: 'user' },
|
||||||
|
],
|
||||||
|
'room-beta': [
|
||||||
|
{ id: 'beta-msg', roomId: 'room-beta', senderId: 'user-1', senderName: 'Bob', content: 'Beta room message', timestamp: 1_790_000_100, role: 'user' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockGroupChatApi(page: Page) {
|
||||||
|
await page.route('**/*', async (route: Route) => {
|
||||||
|
const request = route.request()
|
||||||
|
const url = new URL(request.url())
|
||||||
|
const { pathname } = url
|
||||||
|
|
||||||
|
if (!(pathname === '/health' || pathname.startsWith('/api/'))) {
|
||||||
|
await route.continue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||||
|
|
||||||
|
if (pathname === '/health') return json({ status: 'ok' })
|
||||||
|
if (pathname === '/api/auth/status') return json({ hasPasswordLogin: false, username: null })
|
||||||
|
if (pathname === '/api/hermes/profiles') return json({ profiles: [{ name: 'default', active: true, model: 'test-model', gateway: 'test' }] })
|
||||||
|
if (pathname === '/api/hermes/group-chat/rooms') return json({ rooms })
|
||||||
|
|
||||||
|
const detailMatch = pathname.match(/^\/api\/hermes\/group-chat\/rooms\/([^/]+)$/)
|
||||||
|
if (detailMatch) {
|
||||||
|
const roomId = decodeURIComponent(detailMatch[1])
|
||||||
|
const room = rooms.find(r => r.id === roomId)
|
||||||
|
return room
|
||||||
|
? json({ room, messages: messagesByRoom[roomId] || [], agents: [], members: [{ id: 'member-1', userId: 'user-1', name: 'User One', description: '', joinedAt: 1_790_000_000 }] })
|
||||||
|
: json({ error: 'Room not found' }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ error: `Unexpected mocked route: ${request.method()} ${pathname}` }, 404)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockGroupChatSocket(page: Page) {
|
||||||
|
await page.route('**/node_modules/.vite/deps/socket__io-client.js*', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/javascript',
|
||||||
|
body: `
|
||||||
|
const state = window.__PW_GROUP_SOCKET__ || (window.__PW_GROUP_SOCKET__ = { sockets: [], emitted: [] })
|
||||||
|
const roomMessages = ${JSON.stringify(messagesByRoom)}
|
||||||
|
function makeSocket(url, options) {
|
||||||
|
const listeners = new Map()
|
||||||
|
const socket = {
|
||||||
|
connected: true,
|
||||||
|
url,
|
||||||
|
options,
|
||||||
|
on(event, handler) {
|
||||||
|
const handlers = listeners.get(event) || []
|
||||||
|
handlers.push(handler)
|
||||||
|
listeners.set(event, handlers)
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
emit(event, payload, ack) {
|
||||||
|
state.emitted.push({ event, payload })
|
||||||
|
if (event === 'join' && typeof ack === 'function') {
|
||||||
|
const roomId = payload && payload.roomId
|
||||||
|
setTimeout(() => ack({ roomId, roomName: roomId, members: [], messages: roomMessages[roomId] || [], agents: [], rooms: [], typingUsers: [], contextStatuses: [] }), 0)
|
||||||
|
}
|
||||||
|
if (event === 'message' && typeof ack === 'function') {
|
||||||
|
setTimeout(() => ack({ id: payload && payload.id }), 0)
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
removeAllListeners() {
|
||||||
|
listeners.clear()
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
disconnect() {
|
||||||
|
this.connected = false
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
__trigger(event, payload) {
|
||||||
|
for (const handler of listeners.get(event) || []) handler(payload)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
state.sockets.push(socket)
|
||||||
|
state.latest = socket
|
||||||
|
return socket
|
||||||
|
}
|
||||||
|
export function io(url, options) {
|
||||||
|
return makeSocket(url, options)
|
||||||
|
}
|
||||||
|
export default { io }
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(page: Page, path: string) {
|
||||||
|
await authenticate(page)
|
||||||
|
await mockGroupChatSocket(page)
|
||||||
|
await mockGroupChatApi(page)
|
||||||
|
await page.goto(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('group chat room deep links', () => {
|
||||||
|
test('route room id opens selected room', async ({ page }) => {
|
||||||
|
await setup(page, '/#/hermes/group-chat/room/room-beta')
|
||||||
|
|
||||||
|
await expect(page.locator('.room-title-text', { hasText: 'Beta Room' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking another room updates URL and reload preserves it', async ({ page }) => {
|
||||||
|
await setup(page, '/#/hermes/group-chat/room/room-alpha')
|
||||||
|
await expect(page.getByText('Alpha room message')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByText('Beta Room').click()
|
||||||
|
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||||
|
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||||
|
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('two tabs can show different rooms', async ({ context }) => {
|
||||||
|
const first = await context.newPage()
|
||||||
|
const second = await context.newPage()
|
||||||
|
|
||||||
|
await setup(first, '/#/hermes/group-chat/room/room-alpha')
|
||||||
|
await setup(second, '/#/hermes/group-chat/room/room-beta')
|
||||||
|
|
||||||
|
await expect(first.getByText('Alpha room message')).toBeVisible()
|
||||||
|
await expect(first.getByText('Beta room message')).toHaveCount(0)
|
||||||
|
await expect(second.getByText('Beta room message')).toBeVisible()
|
||||||
|
await expect(second.getByText('Alpha room message')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unknown route room id falls back to base group chat route', async ({ page }) => {
|
||||||
|
await setup(page, '/#/hermes/group-chat/room/missing-room')
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/#\/hermes\/group-chat$/)
|
||||||
|
await expect(page.getByText('Alpha Room')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test'
|
||||||
|
import { authenticate } from './fixtures'
|
||||||
|
|
||||||
|
const historySessions = [
|
||||||
|
{
|
||||||
|
id: 'hist-alpha',
|
||||||
|
profile: 'default',
|
||||||
|
source: 'cli',
|
||||||
|
model: 'test-model',
|
||||||
|
provider: 'test-provider',
|
||||||
|
title: 'Alpha History Session',
|
||||||
|
preview: 'Alpha preview',
|
||||||
|
started_at: 1_790_000_000,
|
||||||
|
ended_at: null,
|
||||||
|
last_active: 1_790_000_100,
|
||||||
|
message_count: 2,
|
||||||
|
tool_call_count: 0,
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 20,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
reasoning_tokens: 0,
|
||||||
|
billing_provider: null,
|
||||||
|
estimated_cost_usd: 0,
|
||||||
|
actual_cost_usd: null,
|
||||||
|
cost_status: '',
|
||||||
|
workspace: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hist-beta',
|
||||||
|
profile: 'default',
|
||||||
|
source: 'cli',
|
||||||
|
model: 'test-model',
|
||||||
|
provider: 'test-provider',
|
||||||
|
title: 'Beta History Session',
|
||||||
|
preview: 'Beta preview',
|
||||||
|
started_at: 1_790_000_200,
|
||||||
|
ended_at: null,
|
||||||
|
last_active: 1_790_000_300,
|
||||||
|
message_count: 2,
|
||||||
|
tool_call_count: 0,
|
||||||
|
input_tokens: 30,
|
||||||
|
output_tokens: 40,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
reasoning_tokens: 0,
|
||||||
|
billing_provider: null,
|
||||||
|
estimated_cost_usd: 0,
|
||||||
|
actual_cost_usd: null,
|
||||||
|
cost_status: '',
|
||||||
|
workspace: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function detailFor(id: string) {
|
||||||
|
const session = historySessions.find(s => s.id === id)
|
||||||
|
if (!session) return null
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
session_id: id,
|
||||||
|
role: 'user',
|
||||||
|
content: `Question for ${session.title}`,
|
||||||
|
tool_call_id: null,
|
||||||
|
tool_calls: null,
|
||||||
|
tool_name: null,
|
||||||
|
timestamp: session.started_at,
|
||||||
|
token_count: null,
|
||||||
|
finish_reason: null,
|
||||||
|
reasoning: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
session_id: id,
|
||||||
|
role: 'assistant',
|
||||||
|
content: `Answer from ${session.title}`,
|
||||||
|
tool_call_id: null,
|
||||||
|
tool_calls: null,
|
||||||
|
tool_name: null,
|
||||||
|
timestamp: session.started_at + 1,
|
||||||
|
token_count: null,
|
||||||
|
finish_reason: null,
|
||||||
|
reasoning: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockHistoryApi(page: Page) {
|
||||||
|
await page.route('**/*', async (route: Route) => {
|
||||||
|
const request = route.request()
|
||||||
|
const url = new URL(request.url())
|
||||||
|
const { pathname } = url
|
||||||
|
|
||||||
|
if (!(pathname === '/health' || pathname.startsWith('/api/'))) {
|
||||||
|
await route.continue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||||
|
|
||||||
|
if (pathname === '/health') return json({ status: 'ok' })
|
||||||
|
if (pathname === '/api/auth/status') return json({ hasPasswordLogin: false, username: null })
|
||||||
|
if (pathname === '/api/hermes/available-models') return json({ default: 'test-model', default_provider: 'test-provider', groups: [], allProviders: [], model_aliases: {}, model_visibility: {} })
|
||||||
|
if (pathname === '/api/hermes/profiles') return json({ profiles: [{ name: 'default', active: true, model: 'test-model', gateway: 'test' }] })
|
||||||
|
if (pathname === '/api/hermes/sessions/hermes') return json({ sessions: historySessions })
|
||||||
|
|
||||||
|
const detailMatch = pathname.match(/^\/api\/hermes\/sessions\/hermes\/([^/]+)$/)
|
||||||
|
if (detailMatch) {
|
||||||
|
const detail = detailFor(decodeURIComponent(detailMatch[1]))
|
||||||
|
return detail ? json({ session: detail }) : json({ error: 'Session not found' }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ error: `Unexpected mocked route: ${request.method()} ${pathname}` }, 404)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('history session deep links', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await authenticate(page)
|
||||||
|
await mockHistoryApi(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('route session id opens selected history session', async ({ page }) => {
|
||||||
|
await page.goto('/#/hermes/history/session/hist-beta')
|
||||||
|
|
||||||
|
await expect(page.getByText('Beta History Session').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking another history session updates URL and reload preserves it', async ({ page }) => {
|
||||||
|
await page.goto('/#/hermes/history/session/hist-alpha')
|
||||||
|
await expect(page.getByText('Answer from Alpha History Session')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByText('Beta History Session').first().click()
|
||||||
|
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta\?profile=default$/)
|
||||||
|
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta\?profile=default$/)
|
||||||
|
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unknown route session id falls back to base history route', async ({ page }) => {
|
||||||
|
await page.goto('/#/hermes/history/session/missing-session')
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/#\/hermes\/history$/)
|
||||||
|
await expect(page.getByText('Alpha History Session').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user