| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186 |
- <script setup lang="ts">
- import { NButton, NDropdown, NEmpty, NInput, NScrollbar, NSpin, useDialog, useMessage } from 'naive-ui'
- import { computed, ref } from 'vue'
- import type { AgentSessionItem } from '@/service/agent'
- defineOptions({
- name: 'AiAssistantSessionSidebar',
- })
- const props = defineProps<{
- sessions: AgentSessionItem[]
- currentId: string | null
- runningIds: Set<string>
- loading: boolean
- error: string | null
- collapsible?: boolean
- }>()
- const emit = defineEmits<{
- select: [sessionId: string]
- new: []
- collapse: []
- retry: []
- rename: [sessionId: string, title: string]
- remove: [sessionId: string]
- }>()
- const dialog = useDialog()
- const message = useMessage()
- const keyword = ref('')
- const editingId = ref<string | null>(null)
- const editingTitle = ref('')
- const DAY = 24 * 3600 * 1000
- interface Group {
- label: string
- items: AgentSessionItem[]
- }
- const groups = computed<Group[]>(() => {
- const kw = keyword.value.trim().toLowerCase()
- const list = props.sessions.filter(s => !kw || (s.title || '新对话').toLowerCase().includes(kw))
- const today = new Date()
- today.setHours(0, 0, 0, 0)
- const t0 = today.getTime()
- const buckets: Group[] = [
- { label: '今天', items: [] },
- { label: '昨天', items: [] },
- { label: '最近 7 天', items: [] },
- { label: '更早', items: [] },
- ]
- for (const s of list) {
- const t = s.lastActive || s.createdAt
- if (t >= t0) buckets[0].items.push(s)
- else if (t >= t0 - DAY) buckets[1].items.push(s)
- else if (t >= t0 - 6 * DAY) buckets[2].items.push(s)
- else buckets[3].items.push(s)
- }
- return buckets.filter(g => g.items.length)
- })
- const menuOptions = [
- { label: '重命名', key: 'rename' },
- { label: '删除', key: 'remove' },
- ]
- function onMenu(key: string, s: AgentSessionItem) {
- if (key === 'rename') {
- editingId.value = s.sessionId
- editingTitle.value = s.title
- }
- else if (key === 'remove') {
- dialog.warning({
- title: '删除会话',
- content: `确定删除“${s.title || '新对话'}”吗?会话中的消息和文件将一并删除,无法恢复。`,
- positiveText: '删除',
- negativeText: '取消',
- onPositiveClick: () => emit('remove', s.sessionId),
- })
- }
- }
- function commitRename() {
- const id = editingId.value
- const title = editingTitle.value.trim()
- editingId.value = null
- if (!id) return
- if (!title) {
- message.warning('标题不能为空')
- return
- }
- const old = props.sessions.find(s => s.sessionId === id)?.title
- if (title !== old) emit('rename', id, title)
- }
- </script>
- <template>
- <div class="flex flex-col h-full">
- <div class="p-3 pb-2 flex gap-2 items-center">
- <NButton class="flex-1" secondary type="primary" @click="emit('new')">
- <template #icon>
- <span class="i-ph:plus" />
- </template>
- 新对话
- </NButton>
- <NButton v-if="collapsible" quaternary circle title="收起会话栏" @click="emit('collapse')">
- <template #icon>
- <span class="i-ph:sidebar-simple" />
- </template>
- </NButton>
- </div>
- <div class="px-3 pb-2">
- <NInput v-model:value="keyword" size="small" clearable placeholder="搜索会话">
- <template #prefix>
- <span class="i-ph:magnifying-glass" />
- </template>
- </NInput>
- </div>
- <NScrollbar class="flex-1 min-h-0">
- <div class="px-2 pb-3">
- <NSpin v-if="loading && !sessions.length" class="py-6 w-full" size="small" />
- <div v-else-if="error && !sessions.length" class="text-xs text-neutral-500 py-6 text-center">
- <div>{{ error }}</div>
- <NButton class="mt-2" size="tiny" @click="emit('retry')">
- 重试
- </NButton>
- </div>
- <NEmpty v-else-if="!groups.length" class="py-8" size="small" :description="keyword ? '没有匹配的会话' : '还没有会话'" />
- <div v-for="g in groups" :key="g.label" class="mt-2">
- <div class="text-xs text-neutral-500 px-2 py-1">
- {{ g.label }}
- </div>
- <div
- v-for="s in g.items"
- :key="s.sessionId"
- class="group text-sm px-2 py-1.5 rounded-md flex gap-2 cursor-pointer transition-colors items-center relative hover:bg-neutral-500/10"
- :class="{ 'session-active': s.sessionId === currentId }"
- @click="editingId !== s.sessionId && emit('select', s.sessionId)"
- >
- <span
- v-if="runningIds.has(s.sessionId)"
- class="rounded-full bg-green-500 shrink-0 size-1.5"
- title="进行中"
- />
- <NInput
- v-if="editingId === s.sessionId"
- v-model:value="editingTitle"
- autofocus
- size="tiny"
- maxlength="60"
- @click.stop
- @blur="commitRename"
- @keydown.enter.prevent="commitRename"
- @keydown.esc="editingId = null"
- />
- <span v-else class="flex-1 min-w-0 truncate" :title="s.title">{{ s.title || '新对话' }}</span>
- <NDropdown
- v-if="editingId !== s.sessionId"
- trigger="click"
- :options="menuOptions"
- @select="(key: string) => onMenu(key, s)"
- >
- <span
- class="i-ph:dots-three-bold opacity-0 shrink-0 transition-opacity group-hover:opacity-70 hover:opacity-100"
- :class="{ 'opacity-60': s.sessionId === currentId }"
- @click.stop
- />
- </NDropdown>
- </div>
- </div>
- </div>
- </NScrollbar>
- </div>
- </template>
- <style scoped>
- .session-active {
- color: var(--primary-color);
- background: color-mix(in srgb, var(--primary-color) 12%, transparent);
- }
- </style>
|