feat: add session grouping by source with collapsible accordion
- Group sessions by source in sidebar (api_server first, cron last) - Accordion behavior: only one group expanded at a time - Auto-select first session when expanding a group - Backfill session titles from first user message in listSessions - Remove chat header model badge - Fix toolPreview type error for non-string values - New chats default to api_server source Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,12 +53,20 @@ export async function listSessions(source?: string, limit?: number): Promise<Her
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const raw: HermesSessionFull = JSON.parse(line)
|
const raw: HermesSessionFull = JSON.parse(line)
|
||||||
|
let title = raw.title
|
||||||
|
if (!title && raw.messages) {
|
||||||
|
const firstUser = raw.messages.find((m: any) => m.role === 'user')
|
||||||
|
if (firstUser?.content) {
|
||||||
|
const t = String(firstUser.content).slice(0, 40)
|
||||||
|
title = t + (String(firstUser.content).length > 40 ? '...' : '')
|
||||||
|
}
|
||||||
|
}
|
||||||
sessions.push({
|
sessions.push({
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
source: raw.source,
|
source: raw.source,
|
||||||
user_id: raw.user_id,
|
user_id: raw.user_id,
|
||||||
model: raw.model,
|
model: raw.model,
|
||||||
title: raw.title,
|
title,
|
||||||
started_at: raw.started_at,
|
started_at: raw.started_at,
|
||||||
ended_at: raw.ended_at,
|
ended_at: raw.ended_at,
|
||||||
end_reason: raw.end_reason,
|
end_reason: raw.end_reason,
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { renameSession } from '@/api/sessions'
|
import { renameSession } from '@/api/sessions'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useChatStore, type Session } from '@/stores/chat'
|
||||||
import { useChatStore } from '@/stores/chat'
|
|
||||||
import { NButton, NDropdown, NInput, NModal, NPopconfirm, NTooltip, useMessage } from 'naive-ui'
|
import { NButton, NDropdown, NInput, NModal, NPopconfirm, NTooltip, useMessage } from 'naive-ui'
|
||||||
import { computed, nextTick, ref } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import ChatInput from './ChatInput.vue'
|
import ChatInput from './ChatInput.vue'
|
||||||
import MessageList from './MessageList.vue'
|
import MessageList from './MessageList.vue'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const appStore = useAppStore()
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const showSessions = ref(true)
|
const showSessions = ref(true)
|
||||||
@@ -16,11 +14,68 @@ const showRenameModal = ref(false)
|
|||||||
const renameValue = ref('')
|
const renameValue = ref('')
|
||||||
const renameSessionId = ref<string | null>(null)
|
const renameSessionId = ref<string | null>(null)
|
||||||
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
|
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
|
||||||
|
const collapsedGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const sortedSessions = computed(() => {
|
// Source sort order: api_server first, cron last, others alphabetical
|
||||||
return [...chatStore.sessions].sort((a, b) => b.createdAt - a.createdAt)
|
function sourceSortKey(source: string): number {
|
||||||
|
if (source === 'api_server') return -1
|
||||||
|
if (source === 'cron') return 999
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group sessions by source, with sort order
|
||||||
|
interface SessionGroup {
|
||||||
|
source: string
|
||||||
|
label: string
|
||||||
|
sessions: Session[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedSessions = computed<SessionGroup[]>(() => {
|
||||||
|
const all = [...chatStore.sessions].sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
|
||||||
|
const map = new Map<string, Session[]>()
|
||||||
|
for (const s of all) {
|
||||||
|
const key = s.source || ''
|
||||||
|
if (!map.has(key)) map.set(key, [])
|
||||||
|
map.get(key)!.push(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = [...map.keys()].sort((a, b) => {
|
||||||
|
const ka = sourceSortKey(a)
|
||||||
|
const kb = sourceSortKey(b)
|
||||||
|
if (ka !== kb) return ka - kb
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
return keys.map(key => ({
|
||||||
|
source: key,
|
||||||
|
label: key ? getSourceLabel(key) : 'Other',
|
||||||
|
sessions: map.get(key)!,
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function toggleGroup(source: string) {
|
||||||
|
const isExpanded = !collapsedGroups.value.has(source)
|
||||||
|
if (isExpanded) {
|
||||||
|
collapsedGroups.value = new Set([...collapsedGroups.value, source])
|
||||||
|
} else {
|
||||||
|
collapsedGroups.value = new Set(
|
||||||
|
groupedSessions.value.map(g => g.source).filter(s => s !== source)
|
||||||
|
)
|
||||||
|
// Auto-select the first session in the expanded group
|
||||||
|
const group = groupedSessions.value.find(g => g.source === source)
|
||||||
|
if (group?.sessions.length) {
|
||||||
|
chatStore.switchSession(group.sessions[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: expand only the first group, collapse the rest
|
||||||
|
watch(groupedSessions, (groups) => {
|
||||||
|
if (collapsedGroups.value.size > 0) return
|
||||||
|
collapsedGroups.value = new Set(groups.map(g => g.source))
|
||||||
|
}, { once: true })
|
||||||
|
|
||||||
const activeSessionTitle = computed(() =>
|
const activeSessionTitle = computed(() =>
|
||||||
chatStore.activeSession?.title || 'New Chat',
|
chatStore.activeSession?.title || 'New Chat',
|
||||||
)
|
)
|
||||||
@@ -29,9 +84,6 @@ const activeSessionSource = computed(() =>
|
|||||||
chatStore.activeSession?.source || '',
|
chatStore.activeSession?.source || '',
|
||||||
)
|
)
|
||||||
|
|
||||||
const sessionModelLabel = computed(() =>
|
|
||||||
chatStore.activeSession?.model || appStore.selectedModel || '',
|
|
||||||
)
|
|
||||||
|
|
||||||
const sourceLabel: Record<string, string> = {
|
const sourceLabel: Record<string, string> = {
|
||||||
telegram: 'Telegram',
|
telegram: 'Telegram',
|
||||||
@@ -50,6 +102,7 @@ const sourceLabel: Record<string, string> = {
|
|||||||
weixin: 'WeChat',
|
weixin: 'WeChat',
|
||||||
bluebubbles: 'iMessage',
|
bluebubbles: 'iMessage',
|
||||||
mattermost: 'Mattermost',
|
mattermost: 'Mattermost',
|
||||||
|
cron: 'Cron',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSourceLabel(source?: string): string {
|
function getSourceLabel(source?: string): string {
|
||||||
@@ -151,10 +204,17 @@ async function handleRenameConfirm() {
|
|||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showSessions" class="session-items">
|
<div v-if="showSessions" class="session-items">
|
||||||
<div v-if="chatStore.isLoadingSessions && sortedSessions.length === 0" class="session-loading">Loading...</div>
|
<div v-if="chatStore.isLoadingSessions && chatStore.sessions.length === 0" class="session-loading">Loading...</div>
|
||||||
<div v-else-if="sortedSessions.length === 0" class="session-empty">No sessions</div>
|
<div v-else-if="chatStore.sessions.length === 0" class="session-empty">No sessions</div>
|
||||||
|
<template v-for="group in groupedSessions" :key="group.source">
|
||||||
|
<div class="session-group-header" @click="toggleGroup(group.source)">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="group-chevron" :class="{ collapsed: collapsedGroups.has(group.source) }"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
<span class="session-group-label">{{ group.label }}</span>
|
||||||
|
<span class="session-group-count">{{ group.sessions.length }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="!collapsedGroups.has(group.source)">
|
||||||
<button
|
<button
|
||||||
v-for="s in sortedSessions"
|
v-for="s in group.sessions"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
class="session-item"
|
class="session-item"
|
||||||
:class="{ active: s.id === chatStore.activeSessionId }"
|
:class="{ active: s.id === chatStore.activeSessionId }"
|
||||||
@@ -165,12 +225,11 @@ async function handleRenameConfirm() {
|
|||||||
<span class="session-item-title">{{ s.title }}</span>
|
<span class="session-item-title">{{ s.title }}</span>
|
||||||
<span class="session-item-meta">
|
<span class="session-item-meta">
|
||||||
<span v-if="s.model" class="session-item-model">{{ s.model }}</span>
|
<span v-if="s.model" class="session-item-model">{{ s.model }}</span>
|
||||||
<!-- <span v-if="s.source" class="session-item-source">{{ getSourceLabel(s.source) }}</span> -->
|
|
||||||
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
|
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<NPopconfirm
|
<NPopconfirm
|
||||||
v-if="s.id !== chatStore.activeSessionId || sortedSessions.length > 1"
|
v-if="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1"
|
||||||
@positive-click="handleDeleteSession(s.id)"
|
@positive-click="handleDeleteSession(s.id)"
|
||||||
>
|
>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
@@ -181,6 +240,8 @@ async function handleRenameConfirm() {
|
|||||||
Delete this session?
|
Delete this session?
|
||||||
</NPopconfirm>
|
</NPopconfirm>
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -225,9 +286,6 @@ async function handleRenameConfirm() {
|
|||||||
<span class="header-session-title">{{ activeSessionTitle }}</span>
|
<span class="header-session-title">{{ activeSessionTitle }}</span>
|
||||||
<span v-if="activeSessionSource" class="source-badge">{{ getSourceLabel(activeSessionSource) }}</span>
|
<span v-if="activeSessionSource" class="source-badge">{{ getSourceLabel(activeSessionSource) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-center">
|
|
||||||
<span v-if="sessionModelLabel" class="model-badge">{{ sessionModelLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<NTooltip trigger="hover">
|
<NTooltip trigger="hover">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
@@ -295,6 +353,39 @@ async function handleRenameConfirm() {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 10px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
|
||||||
|
&.collapsed {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-group-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-muted;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-group-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: $text-muted;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.session-items {
|
.session-items {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -379,17 +470,6 @@ async function handleRenameConfirm() {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-item-source {
|
|
||||||
font-size: 10px;
|
|
||||||
color: $text-muted;
|
|
||||||
background: rgba($text-muted, 0.1);
|
|
||||||
padding: 0 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
line-height: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item-delete {
|
.session-item-delete {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -433,12 +513,6 @@ async function handleRenameConfirm() {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-center {
|
|
||||||
flex-shrink: 0;
|
|
||||||
max-width: 240px;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-session-title {
|
.header-session-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -459,16 +533,6 @@ async function handleRenameConfirm() {
|
|||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-badge {
|
|
||||||
font-size: 11px;
|
|
||||||
color: $text-muted;
|
|
||||||
background: rgba($accent-primary, 0.1);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid $border-color;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+3
-17
@@ -118,7 +118,7 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] {
|
|||||||
timestamp: Math.round(msg.timestamp * 1000),
|
timestamp: Math.round(msg.timestamp * 1000),
|
||||||
toolName,
|
toolName,
|
||||||
toolArgs,
|
toolArgs,
|
||||||
toolPreview: preview.slice(0, 100) || undefined,
|
toolPreview: typeof preview === 'string' ? preview.slice(0, 100) || undefined : undefined,
|
||||||
toolResult: msg.content || undefined,
|
toolResult: msg.content || undefined,
|
||||||
toolStatus: 'done',
|
toolStatus: 'done',
|
||||||
})
|
})
|
||||||
@@ -166,22 +166,6 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
try {
|
try {
|
||||||
const list = await fetchSessions()
|
const list = await fetchSessions()
|
||||||
sessions.value = list.map(mapHermesSession)
|
sessions.value = list.map(mapHermesSession)
|
||||||
// Backfill titles from first user message for sessions with null title
|
|
||||||
const nullTitleSessions = sessions.value.filter(s => s.title === 'New Chat')
|
|
||||||
if (nullTitleSessions.length > 0) {
|
|
||||||
await Promise.allSettled(
|
|
||||||
nullTitleSessions.map(async (s) => {
|
|
||||||
const detail = await fetchSession(s.id)
|
|
||||||
if (detail?.messages) {
|
|
||||||
const firstUser = detail.messages.find(m => m.role === 'user')
|
|
||||||
if (firstUser) {
|
|
||||||
const t = firstUser.content.slice(0, 40)
|
|
||||||
s.title = t + (firstUser.content.length > 40 ? '...' : '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Auto-select the most recent session
|
// Auto-select the most recent session
|
||||||
if (!activeSessionId.value && sessions.value.length > 0) {
|
if (!activeSessionId.value && sessions.value.length > 0) {
|
||||||
await switchSession(sessions.value[0].id)
|
await switchSession(sessions.value[0].id)
|
||||||
@@ -193,10 +177,12 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function createSession(): Session {
|
function createSession(): Session {
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
title: 'New Chat',
|
title: 'New Chat',
|
||||||
|
source: 'api_server',
|
||||||
messages: [],
|
messages: [],
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
|||||||
Reference in New Issue
Block a user