ChatView.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  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 ChoiceDialog from '@/components/common/ChoiceDialog.vue'
  6. import type { ChoiceOption } from '@/components/common/types'
  7. import ComposerShell from '@/components/composer/ComposerShell.vue'
  8. import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
  9. import PreviewInspector from '@/components/library/PreviewInspector.vue'
  10. import type { UiLibraryItem } from '@/components/library/types'
  11. import MessageTimeline from '@/components/messages/MessageTimeline.vue'
  12. import type { UiMessage } from '@/components/messages/types'
  13. import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
  14. import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
  15. import { chatInteraction, chatMessageToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
  16. import { consumeLibraryItemForComposer } from '@/components/workspace/pendingComposer'
  17. import { useAuthStore } from '@/stores/auth'
  18. import { useChatStore } from '@/stores/chat'
  19. const auth = useAuthStore()
  20. const chat = useChatStore()
  21. const route = useRoute()
  22. const router = useRouter()
  23. const search = ref('')
  24. const quoted = ref<UiMessage | null>(null)
  25. const preview = ref<UiLibraryItem | null>(null)
  26. const modelDialog = ref(false)
  27. const showTools = ref(true)
  28. const queueMode = ref(false)
  29. const actionSessionId = ref('')
  30. const renaming = ref(false)
  31. const renameValue = ref('')
  32. const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
  33. const timeline = ref<InstanceType<typeof MessageTimeline> | null>(null)
  34. const sessions = computed(() => [...chat.sessions]
  35. .filter(session => !['cron', 'ios_group'].includes(session.source))
  36. .filter(session => !search.value.trim() || `${session.title} ${session.preview || ''}`.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())))
  37. const agentNames = computed(() => new Map(auth.profiles.map(profile => [
  38. profile.name,
  39. profile.agentName || profile.displayName || profile.name,
  40. ])))
  41. const sidebarItems = computed(() => sessions.value.map(session => sessionSidebarItem(
  42. session,
  43. chat.unreadCounts[session.id] ?? 0,
  44. agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
  45. )))
  46. const messages = computed(() => chat.messages.map(chatMessageToUi))
  47. const activeSession = computed(() => chat.activeSession)
  48. const connected = computed(() => ['connected', 'ready'].includes(chat.connectionState))
  49. const interaction = computed(() => chatInteraction(chat.pendingApproval, chat.pendingClarification))
  50. const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, content: quoted.value.content, author: quoted.value.author } : null)
  51. const forkSourceTitle = computed(() => {
  52. const parentId = activeSession.value?.parentSessionId
  53. if (!parentId) return ''
  54. return chat.sessions.find(session => session.id === parentId && session.profile === activeSession.value?.profile)?.title || parentId
  55. })
  56. const modelOptions = computed<ChoiceOption[]>(() => chat.models.map(model => ({ id: `${model.provider}:${model.id}`, label: model.name || model.id, description: model.provider })))
  57. const selectedModelId = computed(() => chat.selectedModel ? `${chat.selectedModel.provider}:${chat.selectedModel.id}` : '')
  58. const reasoningOptions: ChoiceOption[] = [
  59. { id: '', label: '默认', description: '使用模型或 Profile 的默认强度' },
  60. { id: 'none', label: '关闭', description: '不使用额外推理' },
  61. { id: 'low', label: '低', description: '更快的轻量推理' },
  62. { id: 'medium', label: '中', description: '速度与深度平衡' },
  63. { id: 'high', label: '高', description: '更深入地分析复杂问题' },
  64. { id: 'xhigh', label: '极高', description: '适用于最复杂的任务' },
  65. ]
  66. const reasoningComposerOptions = computed<ComposerOption[]>(() => reasoningOptions.map(option => ({
  67. id: option.id,
  68. label: option.label,
  69. detail: option.description,
  70. })))
  71. const reasoningLabel = computed(() => reasoningOptions.find(option => option.id === (chat.reasoningEffort || ''))?.label || '默认')
  72. async function refreshSessions() {
  73. const profile = auth.activeProfile?.name
  74. // The session route is the authoritative navigation target. A secondary
  75. // unread-count request must never prevent its history from being restored.
  76. await chat.loadSessions(profile)
  77. const requestedId = typeof route.params.sessionId === 'string' ? route.params.sessionId : ''
  78. if (requestedId && chat.activeSessionId !== requestedId) await chat.selectSession(requestedId, profile)
  79. void chat.loadUnread(profile).catch(() => undefined)
  80. }
  81. async function chooseSession(id: string) {
  82. const session = chat.sessions.find(item => item.id === id)
  83. await router.push(`/chat/${encodeURIComponent(id)}`)
  84. await chat.selectSession(id, session?.profile)
  85. await chat.markRead(id, session?.profile)
  86. quoted.value = null
  87. }
  88. async function createSession() {
  89. const id = chat.createSession(auth.activeProfile?.name)
  90. await router.push(`/chat/${encodeURIComponent(id)}`)
  91. }
  92. async function send(payload: ComposerSubmit) {
  93. try {
  94. await chat.send(payload.text, payload.files, queueMode.value ? 'queue' : chat.isStreaming ? 'steer' : 'submit')
  95. composer.value?.clearAfterSend()
  96. } catch { /* store exposes the error and the composer keeps its draft */ }
  97. }
  98. function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number]) {
  99. preview.value = { id: attachment.id, name: attachment.name, kind: attachment.kind || 'file', size: attachment.size, previewUrl: attachment.url, downloadUrl: attachment.url }
  100. }
  101. async function selectModel(id: string) {
  102. const [provider, ...rest] = id.split(':')
  103. const model = chat.models.find(item => item.provider === provider && item.id === rest.join(':'))
  104. if (model) await chat.setModel(model)
  105. modelDialog.value = false
  106. }
  107. function selectReasoning(id: string) {
  108. chat.reasoningEffort = id || undefined
  109. }
  110. function openSessionActions(id: string) {
  111. actionSessionId.value = id
  112. const session = chat.sessions.find(item => item.id === id)
  113. renameValue.value = session?.title || ''
  114. renaming.value = false
  115. }
  116. async function saveRename() {
  117. const session = chat.sessions.find(item => item.id === actionSessionId.value)
  118. if (!session || !renameValue.value.trim()) return
  119. await chat.renameSession(session.id, renameValue.value.trim(), session.profile)
  120. actionSessionId.value = ''
  121. }
  122. async function deleteSession() {
  123. const session = chat.sessions.find(item => item.id === actionSessionId.value)
  124. if (!session) return
  125. await chat.removeSession(session.id, session.profile)
  126. actionSessionId.value = ''
  127. if (route.params.sessionId === session.id) await router.replace('/chat')
  128. }
  129. async function branch() {
  130. const id = await chat.branchSession()
  131. if (id) await router.push(`/chat/${encodeURIComponent(id)}`)
  132. }
  133. async function revealSourceMessage() {
  134. const messageId = typeof route.query.message === 'string' ? route.query.message : ''
  135. if (!messageId) return
  136. await nextTick()
  137. for (let attempt = 0; attempt < 10; attempt += 1) {
  138. if (timeline.value?.scrollToMessage(messageId)) return
  139. if (!chat.hasMoreBefore) return
  140. await chat.loadOlder()
  141. await nextTick()
  142. }
  143. }
  144. onMounted(async () => {
  145. await Promise.allSettled([refreshSessions(), chat.loadModels(auth.activeProfile?.name), chat.connect()])
  146. await revealSourceMessage()
  147. const pendingFile = await consumeLibraryItemForComposer()
  148. if (pendingFile) composer.value?.attachFiles([pendingFile])
  149. })
  150. onBeforeUnmount(() => chat.disconnect())
  151. watch(() => auth.activeProfile?.name, async (profile, previous) => {
  152. if (!profile || profile === previous) return
  153. chat.switchProfile(profile)
  154. await router.replace('/chat')
  155. await Promise.allSettled([chat.loadSessions(profile), chat.loadModels(profile), chat.loadUnread(profile), chat.connect()])
  156. })
  157. watch(() => chat.activeSessionId, async id => {
  158. if (!id || route.params.sessionId === id) return
  159. await router.replace(`/chat/${encodeURIComponent(id)}`)
  160. })
  161. </script>
  162. <template>
  163. <WorkspaceView sidebar-title="对话" :sidebar-subtitle="`${sessions.length} 个会话`" :inspector-open="!!preview" @close-inspector="preview = null">
  164. <template #sidebar-action>
  165. <button class="sidebar-primary-action" type="button" title="新建聊天" aria-label="新建聊天" @click="createSession">
  166. <AppIcon name="edit" :size="17" />
  167. <span>新建聊天</span>
  168. </button>
  169. </template>
  170. <template #sidebar>
  171. <ResourceSidebar
  172. :items="sidebarItems"
  173. :active-id="chat.activeSessionId"
  174. :loading="chat.isLoading"
  175. :search="search"
  176. single-line
  177. :has-more="chat.hasMoreSessions"
  178. :loading-more="chat.isLoadingMoreSessions"
  179. search-placeholder="搜索会话"
  180. empty-title="还没有会话"
  181. empty-description="新建会话后,从输入框开始聊天。"
  182. @search="search = $event"
  183. @load-more="chat.loadMoreSessions(auth.activeProfile?.name)"
  184. @select="chooseSession"
  185. @more="openSessionActions"
  186. />
  187. </template>
  188. <div class="chat-workspace">
  189. <MessageTimeline
  190. ref="timeline"
  191. :messages="messages"
  192. :title="activeSession?.title || '新会话'"
  193. :subtitle="activeSession ? `${activeSession.profile || auth.activeProfile?.name || ''} · ${activeSession.model || chat.selectedModel?.name || '默认模型'}` : '选择会话或直接开始输入'"
  194. :loading="chat.activeRouteState?.isLoadingHistory || chat.isLoading"
  195. :loading-older="chat.activeRouteState?.isLoadingHistory"
  196. :has-older="chat.hasMoreBefore"
  197. :connected="connected"
  198. :synced="chat.historySynced"
  199. :show-tools="showTools"
  200. :interaction="interaction"
  201. empty-logo
  202. transparent-header
  203. :fork-source-title="forkSourceTitle"
  204. @load-older="chat.loadOlder"
  205. @quote="quoted = $event"
  206. @branch="branch"
  207. @preview="openAttachment"
  208. @approve="interaction && chat.respondToApproval(interaction.id, $event)"
  209. @clarify="interaction && chat.respondToClarification(interaction.id, $event)"
  210. >
  211. <template #header-actions>
  212. <button v-if="activeSession" class="icon-button header-session-menu" type="button" title="会话操作" aria-label="会话操作" @click="openSessionActions(activeSession.id)"><AppIcon name="dots" :size="18" /></button>
  213. </template>
  214. </MessageTimeline>
  215. <p v-if="chat.error" class="chat-error" role="alert"><AppIcon name="alert" :size="13" />{{ chat.error }}</p>
  216. <ComposerShell
  217. ref="composer"
  218. mode="chat"
  219. :draft-key="`${auth.user?.id || 'local'}:${auth.activeProfile?.name || 'default'}:${chat.activeSessionId || 'new'}`"
  220. :disabled="chat.connectionState === 'failed'"
  221. :streaming="chat.isStreaming"
  222. :sending="chat.isSending"
  223. :model-label="chat.selectedModel?.name || '选择模型'"
  224. :reasoning-effort="reasoningLabel"
  225. :reasoning-value="chat.reasoningEffort || ''"
  226. :reasoning-options="reasoningComposerOptions"
  227. :context-used="chat.contextUsage?.contextTokens || chat.contextUsage?.totalTokens || 0"
  228. :context-limit="chat.contextUsage?.contextLimit || 262144"
  229. :queue-mode="queueMode || chat.isQueued"
  230. :tool-trace-visible="showTools"
  231. :reference="reference"
  232. :slash-commands="[
  233. { id: 'fork', label: 'fork', detail: '从当前会话创建分支' },
  234. { id: 'compact', label: 'compact', detail: '压缩当前会话上下文' },
  235. { id: 'model', label: 'model', detail: '切换模型' },
  236. ]"
  237. @send="send"
  238. @stop="chat.interrupt"
  239. @model-click="modelDialog = true"
  240. @reasoning-change="selectReasoning"
  241. @settings-click="showTools = !showTools"
  242. @queue-toggle="queueMode = !queueMode"
  243. @clear-reference="quoted = null"
  244. />
  245. </div>
  246. <template #inspector><PreviewInspector v-if="preview" :item="preview" @close="preview = null" @add-to-composer="preview = null" /></template>
  247. </WorkspaceView>
  248. <ChoiceDialog :open="modelDialog" title="选择模型" :options="modelOptions" :selected-id="selectedModelId" @close="modelDialog = false" @select="selectModel" />
  249. <Teleport to="body">
  250. <Transition name="choice-fade">
  251. <div v-if="actionSessionId" class="session-action-layer" @mousedown.self="actionSessionId = ''">
  252. <section class="session-actions" role="dialog" aria-modal="true" aria-label="会话操作">
  253. <header><strong>会话操作</strong><button class="icon-button" type="button" aria-label="关闭" @click="actionSessionId = ''"><AppIcon name="close" /></button></header>
  254. <template v-if="renaming">
  255. <label>会话名称<input v-model="renameValue" maxlength="120" autofocus @keydown.enter="saveRename" /></label>
  256. <div><button class="quiet-button" type="button" @click="renaming = false">取消</button><button class="solid-button" type="button" @click="saveRename">保存</button></div>
  257. </template>
  258. <template v-else>
  259. <button class="action-row" type="button" @click="renaming = true"><AppIcon name="edit" :size="15" />重命名</button>
  260. <button class="action-row danger" type="button" @click="deleteSession"><AppIcon name="trash" :size="15" />删除会话</button>
  261. </template>
  262. </section>
  263. </div>
  264. </Transition>
  265. </Teleport>
  266. </template>
  267. <style scoped>
  268. .chat-workspace { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
  269. .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; }
  270. .sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
  271. .sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
  272. .header-session-menu { color: var(--text-muted); background: transparent; }.header-session-menu:hover { color: var(--text-primary); background: var(--surface-soft); }
  273. .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; }
  274. .session-action-layer { position: fixed; z-index: 205; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(4px); }
  275. .session-actions { width: min(340px, 100%); padding: 12px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface-raised); box-shadow: var(--shadow-float); }.session-actions header { display: flex; min-height: 40px; align-items: center; justify-content: space-between; padding: 0 2px 7px 7px; }.session-actions header strong { font-size: 13px; }.action-row { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 0 9px; border: 0; border-radius: 9px; background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; }.action-row:hover { background: var(--surface-soft); color: var(--text-primary); }.action-row.danger { color: var(--danger); }.session-actions label { display: flex; flex-direction: column; gap: 6px; color: var(--text-muted); font-size: 10px; }.session-actions input { height: 38px; padding: 0 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface-soft); color: var(--text-primary); }.session-actions input:focus { border-color: var(--line-strong); }.session-actions > div { display: flex; justify-content: flex-end; gap: 7px; margin-top: 13px; }
  276. @media (max-width: 600px) { .session-action-layer { place-items: end center; padding: 0; }.session-actions { width: 100%; border-radius: 17px 17px 0 0; padding-bottom: max(12px, env(safe-area-inset-bottom)); } }
  277. @media (prefers-reduced-motion: reduce) { .sidebar-primary-action { transition: none; } }
  278. </style>