ChatView.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. <script setup lang="ts">
  2. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  3. import { useRoute, useRouter } from 'vue-router'
  4. import AppIcon from '@/components/common/AppIcon.vue'
  5. import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
  6. import type { ChoiceOption } from '@/components/common/types'
  7. import ComposerShell from '@/components/composer/ComposerShell.vue'
  8. import ModelChoiceDialog from '@/components/composer/ModelChoiceDialog.vue'
  9. import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
  10. import PreviewInspector from '@/components/library/PreviewInspector.vue'
  11. import PreviewModal from '@/components/library/PreviewModal.vue'
  12. import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
  13. import type { PreviewMedia } from '@/components/library/ImagePreviewLightbox.vue'
  14. import type { UiLibraryItem } from '@/components/library/types'
  15. import { mediaItemsFromMessages, mediaUrlIdentity, previewItemFromUrl } from '@/components/library/mediaSequence'
  16. import MessageTimeline from '@/components/messages/MessageTimeline.vue'
  17. import SessionOutline from '@/components/messages/SessionOutline.vue'
  18. import type { UiMessage } from '@/components/messages/types'
  19. import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
  20. import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
  21. import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
  22. import { chatInteraction, chatMessagesToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
  23. import { consumeLibraryItemForComposer, loadComposerFile } from '@/components/workspace/pendingComposer'
  24. import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
  25. import { useAuthStore } from '@/stores/auth'
  26. import { useChatStore } from '@/stores/chat'
  27. import { getSession } from '@/api/sessions'
  28. const auth = useAuthStore()
  29. const chat = useChatStore()
  30. const route = useRoute()
  31. const router = useRouter()
  32. const quoted = ref<UiMessage | null>(null)
  33. const preview = ref<UiLibraryItem | null>(null)
  34. const filePreview = ref<UiLibraryItem | null>(null)
  35. const mediaPreviewIndex = ref<number | null>(null)
  36. const outlineOpen = ref(false)
  37. const modelDialog = ref(false)
  38. const modelSwitching = ref(false)
  39. const showThinking = ref(true)
  40. const queueMode = ref(false)
  41. const actionSessionId = ref('')
  42. const renaming = ref(false)
  43. const renameValue = ref('')
  44. const actionMenuPosition = ref({ x: 8, y: 8 })
  45. const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
  46. const timeline = ref<InstanceType<typeof MessageTimeline> | null>(null)
  47. const headerSessionMenuButton = ref<HTMLButtonElement | null>(null)
  48. const restoringRouteProfile = ref('')
  49. const sessions = computed(() => [...chat.sessions]
  50. .filter(session => !['cron', 'ios_group'].includes(session.source)))
  51. const agentNames = computed(() => new Map(auth.profiles.map(profile => [
  52. profile.name,
  53. profile.agentName || profile.displayName || profile.name,
  54. ])))
  55. const sidebarItems = computed(() => sessions.value.map(session => sessionSidebarItem(
  56. session,
  57. chat.unreadCounts[session.id] ?? 0,
  58. agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
  59. )))
  60. const messages = computed(() => chatMessagesToUi(chat.messages, profile => profile ? agentNames.value.get(profile) : undefined))
  61. const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
  62. const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
  63. const activeSession = computed(() => chat.activeSession)
  64. const actionSession = computed(() => chat.sessions.find(session => session.id === actionSessionId.value))
  65. const connected = computed(() => ['connected', 'ready'].includes(chat.connectionState))
  66. const interaction = computed(() => chatInteraction(chat.pendingApproval, chat.pendingClarification))
  67. const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, content: quoted.value.content, author: quoted.value.author } : null)
  68. const forkSourceTitle = computed(() => {
  69. const parentId = activeSession.value?.parentSessionId
  70. if (!parentId) return ''
  71. return chat.sessions.find(session => session.id === parentId && session.profile === activeSession.value?.profile)?.title || parentId
  72. })
  73. const selectedModelId = computed(() => chat.selectedModel ? `${chat.selectedModel.provider}:${chat.selectedModel.id}` : '')
  74. const reasoningOptions: ChoiceOption[] = [
  75. { id: '', label: '默认', description: '使用模型或 Profile 的默认强度' },
  76. { id: 'none', label: '关闭', description: '不使用额外推理' },
  77. { id: 'low', label: '低', description: '更快的轻量推理' },
  78. { id: 'medium', label: '中', description: '速度与深度平衡' },
  79. { id: 'high', label: '高', description: '更深入地分析复杂问题' },
  80. { id: 'xhigh', label: '极高', description: '适用于最复杂的任务' },
  81. ]
  82. const reasoningComposerOptions = computed<ComposerOption[]>(() => reasoningOptions.map(option => ({
  83. id: option.id,
  84. label: option.label,
  85. detail: option.description,
  86. })))
  87. const reasoningLabel = computed(() => reasoningOptions.find(option => option.id === (chat.reasoningEffort || ''))?.label || '默认')
  88. const actionMenuStyle = computed(() => ({ left: `${actionMenuPosition.value.x}px`, top: `${actionMenuPosition.value.y}px` }))
  89. function routeProfile(): string {
  90. const value = typeof route.query.profile === 'string' ? route.query.profile : ''
  91. return auth.profiles.some(profile => profile.name === value) ? value : ''
  92. }
  93. function sessionLocation(id: string, profile: string) {
  94. return { path: `/chat/${encodeURIComponent(id)}`, query: { profile } }
  95. }
  96. async function findLegacySessionProfile(sessionId: string): Promise<string> {
  97. for (const profile of auth.profiles) {
  98. try {
  99. const session = await getSession(sessionId, profile.name)
  100. if (session.id === sessionId) return profile.name
  101. } catch { /* Try the next profile: 9119 returns 404 for a foreign session. */ }
  102. }
  103. return auth.activeProfile?.name || ''
  104. }
  105. async function ensureRouteProfile(profile: string): Promise<void> {
  106. if (!profile || auth.activeProfile?.name === profile) return
  107. chat.switchProfile(profile)
  108. restoringRouteProfile.value = profile
  109. auth.selectProfile(profile)
  110. await nextTick()
  111. }
  112. async function refreshSessions() {
  113. const requestedId = typeof route.params.sessionId === 'string' ? route.params.sessionId : ''
  114. const profile = routeProfile() || (requestedId ? await findLegacySessionProfile(requestedId) : auth.activeProfile?.name || '')
  115. if (profile && auth.activeProfile?.name !== profile) {
  116. if (requestedId) await router.replace(sessionLocation(requestedId, profile))
  117. await ensureRouteProfile(profile)
  118. }
  119. // The session route is the authoritative navigation target. A secondary
  120. // unread-count request must never prevent its history from being restored.
  121. await chat.loadSessions(profile)
  122. if (requestedId && chat.activeSessionId !== requestedId) await chat.selectSession(requestedId, profile)
  123. if (requestedId && profile && routeProfile() !== profile) await router.replace(sessionLocation(requestedId, profile))
  124. void chat.loadUnread(profile).catch(() => undefined)
  125. }
  126. async function chooseSession(id: string) {
  127. outlineOpen.value = false
  128. preview.value = null
  129. const session = chat.sessions.find(item => item.id === id)
  130. const profile = session?.profile || auth.activeProfile?.name || 'default'
  131. await router.push(sessionLocation(id, profile))
  132. await ensureRouteProfile(profile)
  133. if (chat.activeProfileName !== profile) await chat.loadSessions(profile)
  134. await chat.selectSession(id, profile)
  135. await chat.markRead(id, profile).catch(() => undefined)
  136. quoted.value = null
  137. }
  138. async function createSession() {
  139. outlineOpen.value = false
  140. preview.value = null
  141. const id = chat.createSession(auth.activeProfile?.name)
  142. await router.push(sessionLocation(id, auth.activeProfile?.name || 'default'))
  143. }
  144. async function send(payload: ComposerSubmit) {
  145. try {
  146. await chat.send(payload.text, payload.files, queueMode.value ? 'queue' : chat.isStreaming ? 'steer' : 'submit')
  147. composer.value?.clearAfterSend()
  148. } catch { /* store exposes the error and the composer keeps its draft */ }
  149. }
  150. function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number]) {
  151. const item = previewItemFromUrl(attachment.name, attachment.url || '', attachment.id, attachment.kind)
  152. item.size = attachment.size
  153. if (item.kind === 'image' || item.kind === 'video') openMedia(item)
  154. else { outlineOpen.value = false; preview.value = null; filePreview.value = item }
  155. }
  156. function openMedia(item: UiLibraryItem) {
  157. const target = mediaUrlIdentity(item.previewUrl || item.downloadUrl || '')
  158. const index = lightboxMedia.value.findIndex(media => mediaUrlIdentity(media.url) === target)
  159. mediaPreviewIndex.value = index >= 0 ? index : null
  160. }
  161. function openLocalFile({ name, url }: { name: string; url: string }) {
  162. const item = previewItemFromUrl(name, url)
  163. if (item.kind === 'image' || item.kind === 'video') openMedia(item)
  164. else { outlineOpen.value = false; filePreview.value = item }
  165. }
  166. async function addPreviewToComposer(item: UiLibraryItem) {
  167. const file = await loadComposerFile(item)
  168. if (!file || !composer.value) return
  169. composer.value.attachFiles([file])
  170. preview.value = null
  171. filePreview.value = null
  172. mediaPreviewIndex.value = null
  173. await nextTick()
  174. composer.value?.focus()
  175. }
  176. async function addMediaToComposer(media: PreviewMedia) {
  177. await addPreviewToComposer(previewItemFromUrl(media.name, media.url, `preview:${media.url}`, media.type))
  178. }
  179. async function selectModel(id: string) {
  180. if (id === selectedModelId.value) {
  181. modelDialog.value = false
  182. return
  183. }
  184. const [provider, ...rest] = id.split(':')
  185. const model = chat.models.find(item => item.provider === provider && item.id === rest.join(':'))
  186. if (!model || modelSwitching.value) return
  187. modelSwitching.value = true
  188. try {
  189. await chat.setModel(model)
  190. modelDialog.value = false
  191. } finally {
  192. modelSwitching.value = false
  193. }
  194. }
  195. function selectReasoning(id: string) {
  196. chat.reasoningEffort = id || undefined
  197. }
  198. async function toggleFastMode(enabled: boolean) {
  199. try { await chat.setFastMode(enabled) } catch { /* store restores the acknowledged state */ }
  200. }
  201. function restoreShowThinking(profile = auth.activeProfile?.name || 'default') {
  202. showThinking.value = readAgentShowThinking(auth.user?.id ?? 'local', profile)
  203. }
  204. function toggleShowThinking() {
  205. showThinking.value = !showThinking.value
  206. writeAgentShowThinking(auth.user?.id ?? 'local', auth.activeProfile?.name || 'default', showThinking.value)
  207. }
  208. function openSessionActions(id: string, event?: MouseEvent) {
  209. actionSessionId.value = id
  210. const session = chat.sessions.find(item => item.id === id)
  211. renameValue.value = session?.title || ''
  212. renaming.value = false
  213. const menuWidth = 208
  214. const menuHeight = 176
  215. const inset = 8
  216. const anchor = event?.currentTarget instanceof HTMLElement ? event.currentTarget.getBoundingClientRect() : null
  217. const requestedX = event?.type === 'contextmenu' ? event.clientX : anchor ? anchor.right - menuWidth : window.innerWidth - menuWidth - 14
  218. const requestedY = event?.type === 'contextmenu' ? event.clientY : anchor ? anchor.bottom + 5 : 46
  219. actionMenuPosition.value = {
  220. x: Math.max(inset, Math.min(requestedX, window.innerWidth - menuWidth - inset)),
  221. y: Math.max(inset, Math.min(requestedY, window.innerHeight - menuHeight - inset)),
  222. }
  223. }
  224. function closeSessionActions() {
  225. actionSessionId.value = ''
  226. renaming.value = false
  227. }
  228. function handleSessionActionPointer(event: PointerEvent) {
  229. if (!actionSessionId.value || (event.target as HTMLElement).closest('.session-actions')) return
  230. closeSessionActions()
  231. }
  232. function handleSessionActionKey(event: KeyboardEvent) {
  233. if (event.key === 'Escape' && actionSessionId.value) closeSessionActions()
  234. }
  235. async function saveRename() {
  236. const session = chat.sessions.find(item => item.id === actionSessionId.value)
  237. if (!session || !renameValue.value.trim()) return
  238. await chat.renameSession(session.id, renameValue.value.trim(), session.profile)
  239. closeSessionActions()
  240. }
  241. async function toggleSessionPinned() {
  242. const session = actionSession.value
  243. if (!session) return
  244. await chat.setSessionPinned(session.id, !session.pinned, session.profile)
  245. closeSessionActions()
  246. }
  247. async function openSessionOutline() {
  248. const session = actionSession.value
  249. if (!session) return
  250. closeSessionActions()
  251. if (session.id !== chat.activeSessionId || session.profile !== chat.activeProfileName) await chooseSession(session.id)
  252. preview.value = null
  253. outlineOpen.value = true
  254. }
  255. function closeInspector() {
  256. const restoreMenuFocus = outlineOpen.value
  257. preview.value = null
  258. outlineOpen.value = false
  259. if (restoreMenuFocus) void nextTick(() => headerSessionMenuButton.value?.focus())
  260. }
  261. function navigateOutline(target: { messageId: string; anchorId: string }) {
  262. timeline.value?.scrollToAnchor(target.messageId, target.anchorId)
  263. if (window.matchMedia('(max-width: 900px)').matches) closeInspector()
  264. }
  265. async function deleteSession() {
  266. const session = chat.sessions.find(item => item.id === actionSessionId.value)
  267. if (!session) return
  268. await chat.removeSession(session.id, session.profile)
  269. closeSessionActions()
  270. if (route.params.sessionId === session.id) await router.replace('/chat')
  271. }
  272. async function branch() {
  273. const id = await chat.branchSession()
  274. if (id) await router.push(sessionLocation(id, chat.activeProfileName || auth.activeProfile?.name || 'default'))
  275. }
  276. async function revealSourceMessage() {
  277. const messageId = typeof route.query.message === 'string' ? route.query.message : ''
  278. if (!messageId) return
  279. await nextTick()
  280. for (let attempt = 0; attempt < 10; attempt += 1) {
  281. if (timeline.value?.scrollToMessage(messageId)) return
  282. if (!chat.hasMoreBefore) return
  283. await chat.loadOlder()
  284. await nextTick()
  285. }
  286. }
  287. onMounted(async () => {
  288. restoreShowThinking()
  289. document.addEventListener('pointerdown', handleSessionActionPointer)
  290. document.addEventListener('keydown', handleSessionActionKey)
  291. await refreshSessions()
  292. await Promise.allSettled([chat.loadModels(auth.activeProfile?.name), chat.connect()])
  293. await revealSourceMessage()
  294. const pendingFile = await consumeLibraryItemForComposer()
  295. if (pendingFile) composer.value?.attachFiles([pendingFile])
  296. })
  297. onBeforeUnmount(() => {
  298. document.removeEventListener('pointerdown', handleSessionActionPointer)
  299. document.removeEventListener('keydown', handleSessionActionKey)
  300. chat.disconnect()
  301. })
  302. watch(() => auth.activeProfile?.name, async (profile, previous) => {
  303. if (!profile || profile === previous) return
  304. restoreShowThinking(profile)
  305. outlineOpen.value = false
  306. preview.value = null
  307. if (restoringRouteProfile.value === profile) {
  308. restoringRouteProfile.value = ''
  309. return
  310. }
  311. chat.switchProfile(profile)
  312. const requestedId = typeof route.params.sessionId === 'string' ? route.params.sessionId : ''
  313. if (requestedId && routeProfile() === profile) {
  314. await Promise.allSettled([chat.loadSessions(profile), chat.loadModels(profile), chat.loadUnread(profile), chat.connect()])
  315. await chat.selectSession(requestedId, profile)
  316. return
  317. }
  318. await router.replace({ path: '/chat', query: { profile } })
  319. await Promise.allSettled([chat.loadSessions(profile), chat.loadModels(profile), chat.loadUnread(profile), chat.connect()])
  320. })
  321. watch(() => chat.activeSessionId, async id => {
  322. const profile = chat.activeProfileName || auth.activeProfile?.name || ''
  323. if (!id || (route.params.sessionId === id && routeProfile() === profile)) return
  324. await router.replace(sessionLocation(id, profile))
  325. })
  326. </script>
  327. <template>
  328. <WorkspaceView sidebar-title="对话" :sidebar-subtitle="`${sessions.length} 个会话`" :inspector-open="!!preview || outlineOpen" :inspector-close-label="outlineOpen ? '关闭会话大纲' : '关闭预览'" @close-inspector="closeInspector">
  329. <template #sidebar-action>
  330. <button class="sidebar-primary-action" type="button" title="新建聊天" aria-label="新建聊天" @click="createSession">
  331. <YaoYaoSidebarIcon name="add" />
  332. <span>新建聊天</span>
  333. </button>
  334. </template>
  335. <template #sidebar>
  336. <ResourceSidebar
  337. :items="sidebarItems"
  338. :active-id="chat.activeSessionId"
  339. :loading="chat.isLoading"
  340. external-search
  341. single-line
  342. :has-more="chat.hasMoreSessions"
  343. :loading-more="chat.isLoadingMoreSessions"
  344. search-placeholder="搜索会话"
  345. empty-title="还没有会话"
  346. empty-description="新建会话后,从输入框开始聊天。"
  347. @load-more="chat.loadMoreSessions(auth.activeProfile?.name)"
  348. @select="chooseSession"
  349. @more="openSessionActions"
  350. @context-menu="openSessionActions"
  351. />
  352. </template>
  353. <div class="chat-workspace">
  354. <MessageTimeline
  355. ref="timeline"
  356. :messages="messages"
  357. :title="activeSession?.title || '新会话'"
  358. :subtitle="activeSession ? `${activeSession.profile || auth.activeProfile?.name || ''} · ${activeSession.model || chat.selectedModel?.name || '默认模型'}` : '选择会话或直接开始输入'"
  359. :loading="chat.activeRouteState?.isLoadingHistory || chat.isLoading"
  360. :loading-older="chat.activeRouteState?.isLoadingHistory"
  361. :has-older="chat.hasMoreBefore"
  362. :connected="connected"
  363. :synced="chat.historySynced"
  364. :show-tools="showThinking"
  365. :thinking="chat.isSending || chat.isStreaming"
  366. :show-assistant-identity="false"
  367. :interaction="interaction"
  368. empty-logo
  369. transparent-header
  370. :fork-source-title="forkSourceTitle"
  371. @load-older="chat.loadOlder"
  372. @quote="quoted = $event"
  373. @branch="branch"
  374. @preview="openAttachment"
  375. @preview-file="openLocalFile"
  376. @approve="interaction && chat.respondToApproval(interaction.id, $event)"
  377. @clarify="interaction && chat.respondToClarification(interaction.id, $event)"
  378. >
  379. <template #header-actions>
  380. <button v-if="activeSession" ref="headerSessionMenuButton" class="icon-button header-session-menu" type="button" title="会话操作" aria-label="会话操作" @click="openSessionActions(activeSession.id, $event)"><AppIcon name="dots" :size="18" /></button>
  381. </template>
  382. </MessageTimeline>
  383. <p v-if="chat.error" class="chat-error" role="alert"><AppIcon name="alert" :size="13" />{{ chat.error }}</p>
  384. <ComposerShell
  385. ref="composer"
  386. mode="chat"
  387. :draft-key="`${auth.user?.id || 'local'}:${auth.activeProfile?.name || 'default'}:${chat.activeSessionId || 'new'}`"
  388. :disabled="chat.connectionState === 'failed'"
  389. :streaming="chat.isStreaming"
  390. :sending="chat.isSending"
  391. :model-label="chat.selectedModel?.name || '选择模型'"
  392. :fast-mode="chat.fastMode"
  393. :fast-mode-disabled="!chat.activeSessionId"
  394. :reasoning-effort="reasoningLabel"
  395. :reasoning-value="chat.reasoningEffort || ''"
  396. :reasoning-options="reasoningComposerOptions"
  397. :context-used="chat.contextUsage?.contextTokens || chat.contextUsage?.totalTokens || 0"
  398. :context-limit="chat.contextUsage?.contextLimit || 262144"
  399. :queue-mode="queueMode || chat.isQueued"
  400. :tool-trace-visible="showThinking"
  401. :reference="reference"
  402. :slash-commands="[
  403. { id: 'fork', label: 'fork', detail: '从当前会话创建分支' },
  404. { id: 'compact', label: 'compact', detail: '压缩当前会话上下文' },
  405. { id: 'model', label: 'model', detail: '切换模型' },
  406. ]"
  407. @send="send"
  408. @stop="chat.interrupt"
  409. @model-click="modelDialog = true"
  410. @fast-mode-toggle="toggleFastMode"
  411. @reasoning-change="selectReasoning"
  412. @settings-click="toggleShowThinking"
  413. @queue-toggle="queueMode = !queueMode"
  414. @clear-reference="quoted = null"
  415. />
  416. </div>
  417. <template #inspector>
  418. <SessionOutline v-if="outlineOpen" :messages="messages" :has-older="chat.hasMoreBefore" @navigate="navigateOutline" />
  419. <PreviewInspector v-else-if="preview" :item="preview" @close="preview = null" @add-to-composer="addPreviewToComposer" />
  420. </template>
  421. </WorkspaceView>
  422. <FloatingResourceSearch section="chat" label="搜索会话" :items="sidebarItems" @select="chooseSession" />
  423. <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" @close="filePreview = null" @add-to-composer="addPreviewToComposer" @source="filePreview = null" />
  424. <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" @add="addMediaToComposer" />
  425. <ModelChoiceDialog :open="modelDialog" :options="chat.models" :selected-id="selectedModelId" :busy="modelSwitching" @close="modelDialog = false" @select="selectModel" />
  426. <Teleport to="body">
  427. <Transition name="session-menu">
  428. <section v-if="actionSessionId" class="session-actions" :style="actionMenuStyle" role="menu" aria-label="会话操作" @contextmenu.prevent>
  429. <template v-if="renaming">
  430. <label>会话名称<input v-model="renameValue" maxlength="120" autofocus @keydown.enter="saveRename" /></label>
  431. <div><button class="quiet-button" type="button" @click="renaming = false">取消</button><button class="solid-button" type="button" @click="saveRename">保存</button></div>
  432. </template>
  433. <template v-else>
  434. <button class="action-row" role="menuitem" type="button" @click="openSessionOutline"><AppIcon name="menu" :size="14" />会话大纲</button>
  435. <button class="action-row" role="menuitem" type="button" @click="toggleSessionPinned"><AppIcon :name="actionSession?.pinned ? 'pin-off' : 'pin'" :size="14" />{{ actionSession?.pinned ? '取消置顶' : '置顶会话' }}</button>
  436. <button class="action-row" role="menuitem" type="button" @click="renaming = true"><AppIcon name="edit" :size="14" />重命名</button>
  437. <button class="action-row danger" role="menuitem" type="button" @click="deleteSession"><AppIcon name="trash" :size="14" />删除会话</button>
  438. </template>
  439. </section>
  440. </Transition>
  441. </Teleport>
  442. </template>
  443. <style scoped>
  444. .chat-workspace { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
  445. .sidebar-primary-action { display: flex; width: 100%; min-height: 40px; align-items: center; gap: 10px; padding: 0 11px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; font-size: 12px; font-weight: 610; text-align: left; transition: background-color 120ms ease; }
  446. .sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
  447. .sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
  448. .header-session-menu { color: var(--text-muted); background: transparent; }.header-session-menu:hover { color: var(--text-primary); background: var(--surface-soft); }
  449. .chat-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
  450. .session-actions { position: fixed; z-index: 205; width: 208px; box-sizing: border-box; padding: 6px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-raised); box-shadow: 0 12px 34px rgba(0,0,0,.16); }.action-row { display: flex; width: 100%; min-height: 34px; align-items: center; gap: 8px; padding: 0 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 11px; text-align: left; }.action-row:hover, .action-row:focus-visible { outline: 0; background: var(--surface-soft); color: var(--text-primary); }.action-row.danger { color: var(--danger); }.session-actions label { display: flex; flex-direction: column; gap: 6px; padding: 4px; color: var(--text-muted); font-size: 9px; }.session-actions input { width: 100%; height: 32px; box-sizing: border-box; padding: 0 8px; border: 1px solid var(--line); border-radius: 7px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font-size: 11px; }.session-actions input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.session-actions > div { display: flex; justify-content: flex-end; gap: 5px; margin-top: 7px; padding: 0 4px 3px; }.session-actions :deep(.quiet-button), .session-actions :deep(.solid-button) { min-height: 29px; padding-inline: 10px; font-size: 10px; }
  451. .session-menu-enter-active, .session-menu-leave-active { transition: opacity 100ms ease, transform 120ms var(--ease-out); transform-origin: top right; }.session-menu-enter-from, .session-menu-leave-to { opacity: 0; transform: translateY(-3px) scale(.98); }
  452. @media (prefers-reduced-motion: reduce) { .sidebar-primary-action, .session-menu-enter-active, .session-menu-leave-active { transition: none; } }
  453. </style>