SessionSidebar.vue 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. <script setup lang="ts">
  2. import { NButton, NDropdown, NEmpty, NInput, NScrollbar, NSpin, useDialog, useMessage } from 'naive-ui'
  3. import { computed, ref } from 'vue'
  4. import type { AgentSessionItem } from '@/service/agent'
  5. defineOptions({
  6. name: 'AiAssistantSessionSidebar',
  7. })
  8. const props = defineProps<{
  9. sessions: AgentSessionItem[]
  10. currentId: string | null
  11. runningIds: Set<string>
  12. loading: boolean
  13. error: string | null
  14. collapsible?: boolean
  15. }>()
  16. const emit = defineEmits<{
  17. select: [sessionId: string]
  18. new: []
  19. collapse: []
  20. retry: []
  21. rename: [sessionId: string, title: string]
  22. remove: [sessionId: string]
  23. }>()
  24. const dialog = useDialog()
  25. const message = useMessage()
  26. const keyword = ref('')
  27. const editingId = ref<string | null>(null)
  28. const editingTitle = ref('')
  29. const DAY = 24 * 3600 * 1000
  30. interface Group {
  31. label: string
  32. items: AgentSessionItem[]
  33. }
  34. const groups = computed<Group[]>(() => {
  35. const kw = keyword.value.trim().toLowerCase()
  36. const list = props.sessions.filter(s => !kw || (s.title || '新对话').toLowerCase().includes(kw))
  37. const today = new Date()
  38. today.setHours(0, 0, 0, 0)
  39. const t0 = today.getTime()
  40. const buckets: Group[] = [
  41. { label: '今天', items: [] },
  42. { label: '昨天', items: [] },
  43. { label: '最近 7 天', items: [] },
  44. { label: '更早', items: [] },
  45. ]
  46. for (const s of list) {
  47. const t = s.lastActive || s.createdAt
  48. if (t >= t0) buckets[0].items.push(s)
  49. else if (t >= t0 - DAY) buckets[1].items.push(s)
  50. else if (t >= t0 - 6 * DAY) buckets[2].items.push(s)
  51. else buckets[3].items.push(s)
  52. }
  53. return buckets.filter(g => g.items.length)
  54. })
  55. const menuOptions = [
  56. { label: '重命名', key: 'rename' },
  57. { label: '删除', key: 'remove' },
  58. ]
  59. function onMenu(key: string, s: AgentSessionItem) {
  60. if (key === 'rename') {
  61. editingId.value = s.sessionId
  62. editingTitle.value = s.title
  63. }
  64. else if (key === 'remove') {
  65. dialog.warning({
  66. title: '删除会话',
  67. content: `确定删除“${s.title || '新对话'}”吗?会话中的消息和文件将一并删除,无法恢复。`,
  68. positiveText: '删除',
  69. negativeText: '取消',
  70. onPositiveClick: () => emit('remove', s.sessionId),
  71. })
  72. }
  73. }
  74. function commitRename() {
  75. const id = editingId.value
  76. const title = editingTitle.value.trim()
  77. editingId.value = null
  78. if (!id) return
  79. if (!title) {
  80. message.warning('标题不能为空')
  81. return
  82. }
  83. const old = props.sessions.find(s => s.sessionId === id)?.title
  84. if (title !== old) emit('rename', id, title)
  85. }
  86. </script>
  87. <template>
  88. <div class="flex flex-col h-full">
  89. <div class="p-3 pb-2 flex gap-2 items-center">
  90. <NButton class="flex-1" secondary type="primary" @click="emit('new')">
  91. <template #icon>
  92. <span class="i-ph:plus" />
  93. </template>
  94. 新对话
  95. </NButton>
  96. <NButton v-if="collapsible" quaternary circle title="收起会话栏" @click="emit('collapse')">
  97. <template #icon>
  98. <span class="i-ph:sidebar-simple" />
  99. </template>
  100. </NButton>
  101. </div>
  102. <div class="px-3 pb-2">
  103. <NInput v-model:value="keyword" size="small" clearable placeholder="搜索会话">
  104. <template #prefix>
  105. <span class="i-ph:magnifying-glass" />
  106. </template>
  107. </NInput>
  108. </div>
  109. <NScrollbar class="flex-1 min-h-0">
  110. <div class="px-2 pb-3">
  111. <NSpin v-if="loading && !sessions.length" class="py-6 w-full" size="small" />
  112. <div v-else-if="error && !sessions.length" class="text-xs text-neutral-500 py-6 text-center">
  113. <div>{{ error }}</div>
  114. <NButton class="mt-2" size="tiny" @click="emit('retry')">
  115. 重试
  116. </NButton>
  117. </div>
  118. <NEmpty v-else-if="!groups.length" class="py-8" size="small" :description="keyword ? '没有匹配的会话' : '还没有会话'" />
  119. <div v-for="g in groups" :key="g.label" class="mt-2">
  120. <div class="text-xs text-neutral-500 px-2 py-1">
  121. {{ g.label }}
  122. </div>
  123. <div
  124. v-for="s in g.items"
  125. :key="s.sessionId"
  126. 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"
  127. :class="{ 'session-active': s.sessionId === currentId }"
  128. @click="editingId !== s.sessionId && emit('select', s.sessionId)"
  129. >
  130. <span
  131. v-if="runningIds.has(s.sessionId)"
  132. class="rounded-full bg-green-500 shrink-0 size-1.5"
  133. title="进行中"
  134. />
  135. <NInput
  136. v-if="editingId === s.sessionId"
  137. v-model:value="editingTitle"
  138. autofocus
  139. size="tiny"
  140. maxlength="60"
  141. @click.stop
  142. @blur="commitRename"
  143. @keydown.enter.prevent="commitRename"
  144. @keydown.esc="editingId = null"
  145. />
  146. <span v-else class="flex-1 min-w-0 truncate" :title="s.title">{{ s.title || '新对话' }}</span>
  147. <NDropdown
  148. v-if="editingId !== s.sessionId"
  149. trigger="click"
  150. :options="menuOptions"
  151. @select="(key: string) => onMenu(key, s)"
  152. >
  153. <span
  154. class="i-ph:dots-three-bold opacity-0 shrink-0 transition-opacity group-hover:opacity-70 hover:opacity-100"
  155. :class="{ 'opacity-60': s.sessionId === currentId }"
  156. @click.stop
  157. />
  158. </NDropdown>
  159. </div>
  160. </div>
  161. </div>
  162. </NScrollbar>
  163. </div>
  164. </template>
  165. <style scoped>
  166. .session-active {
  167. color: var(--primary-color);
  168. background: color-mix(in srgb, var(--primary-color) 12%, transparent);
  169. }
  170. </style>