SessionsView.vue 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. <script setup lang="ts">
  2. import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
  3. import { useRoute, useRouter } from 'vue-router'
  4. import { AgentChat, useChatSession, type ChatSession } from '@agent-server/chat-ui'
  5. import type { SessionDetailDto, SessionDto } from '@agent-server/shared'
  6. import DebugPanel from '../components/DebugPanel.vue'
  7. import SidePane from '../components/SidePane.vue'
  8. import { admin, client, errorMessage } from '../lib/api'
  9. import { relativeTime, toast } from '../lib/ui'
  10. // 所有来源(各 Agent Key 与后台)的会话,只读回放
  11. const route = useRoute()
  12. const router = useRouter()
  13. const sessionId = computed(() => (route.params.sessionId as string | undefined) || null)
  14. const sessions = ref<SessionDto[]>([])
  15. const cursor = ref<string | null>(null)
  16. const keyNames = ref<Record<string, string>>({ admin: '后台' })
  17. const filter = ref('')
  18. const chat = shallowRef<ChatSession | null>(null)
  19. const detail = ref<SessionDetailDto | null>(null)
  20. const debugTab = ref<'events' | 'files' | 'session'>('events')
  21. const preview = ref<{ fileId: string; path: string; mime: string; size: number } | null>(null)
  22. const shown = computed(() => sessions.value.filter(s => !filter.value || s.source === filter.value))
  23. async function load(more = false) {
  24. try {
  25. const page = await client.sessions.list({ scope: 'all', limit: 100, cursor: more ? cursor.value ?? undefined : undefined })
  26. sessions.value = more ? [...sessions.value, ...page.items] : page.items
  27. cursor.value = page.nextCursor
  28. } catch (err) {
  29. toast(errorMessage(err), 'error')
  30. }
  31. }
  32. watch(sessionId, async id => {
  33. chat.value?.dispose()
  34. chat.value = null
  35. detail.value = null
  36. preview.value = null
  37. if (!id) return
  38. try {
  39. detail.value = await client.sessions.get(id)
  40. chat.value = useChatSession(client, id)
  41. } catch (err) {
  42. toast(errorMessage(err), 'error')
  43. }
  44. }, { immediate: true })
  45. onMounted(async () => {
  46. void load()
  47. const keys = await admin.apiKeys().catch(() => [])
  48. for (const k of keys) keyNames.value[k.id] = k.name
  49. })
  50. onBeforeUnmount(() => chat.value?.dispose())
  51. </script>
  52. <template>
  53. <SidePane title="会话记录" :width="300">
  54. <template #actions>
  55. <button type="button" class="icon-btn" title="刷新" @click="load()"><span class="codicon codicon-refresh" /></button>
  56. </template>
  57. <template #side>
  58. <div class="px-3 pb-2">
  59. <select v-model="filter" class="field" aria-label="来源">
  60. <option value="">全部来源</option>
  61. <option v-for="(name, id) in keyNames" :key="id" :value="id">{{ name }}</option>
  62. </select>
  63. </div>
  64. <div
  65. v-for="s in shown" :key="s.id" class="flex items-center gap-2 h-[48px] pl-5 pr-3 cursor-pointer hover:bg-hover"
  66. :class="{ '!bg-active': s.id === sessionId }" @click="router.push(`/sessions/${s.id}`)"
  67. >
  68. <div class="flex-1 min-w-0">
  69. <div class="truncate">{{ s.title || '新会话' }}</div>
  70. <div class="text-[11px] text-fg-muted truncate">{{ keyNames[s.source] ?? s.source }} · {{ s.model }} · {{ relativeTime(s.updatedAt) }}</div>
  71. </div>
  72. <span v-if="s.status !== 'idle'" class="w-2 h-2 rounded-full flex-none bg-accent" />
  73. </div>
  74. <button v-if="cursor" type="button" class="btn-ghost w-full" @click="load(true)">加载更多</button>
  75. </template>
  76. <div v-if="!sessionId" class="flex-1 flex items-center justify-center hint bg-bg-side">选择左侧的会话查看完整过程</div>
  77. <template v-else>
  78. <header class="h-[35px] flex-none flex items-center gap-2 px-4 border-b border-b-solid border-border bg-bg-side">
  79. <span class="font-500 truncate">{{ detail?.title || '新会话' }}</span>
  80. <span class="hint">只读回放</span>
  81. <span class="flex-1" />
  82. <button v-if="detail?.source === 'admin'" type="button" class="btn-ghost" @click="router.push(`/playground/${sessionId}`)">
  83. <span class="codicon codicon-comment-discussion" />在 Playground 中继续
  84. </button>
  85. </header>
  86. <div class="flex-1 min-h-0 flex">
  87. <div class="flex-1 min-w-0">
  88. <AgentChat v-if="chat" :key="sessionId!" :client="client" :session="chat" readonly @open-file="f => { debugTab = 'files'; preview = f }" />
  89. </div>
  90. <div v-if="chat" class="w-[380px] flex-none min-h-0">
  91. <DebugPanel v-model:tab="debugTab" v-model:preview="preview" :state="chat.state" :session="detail" />
  92. </div>
  93. </div>
  94. </template>
  95. </SidePane>
  96. </template>