feat: 添加右侧悬浮书签导航栏

This commit is contained in:
ViperEkura 2026-03-26 17:09:22 +08:00
parent 95e771cb61
commit 8c29f0684f
2 changed files with 241 additions and 71 deletions

View File

@ -8,82 +8,94 @@
<template v-else> <template v-else>
<div class="chat-header"> <div class="chat-header">
<div class="chat-title-area"> <div class="chat-title-area">
<h2 class="chat-title">{{ conversation.title || '新对话' }}</h2> <h2 class="chat-title">{{ conversation.title || '新对话' }}</h2>
<span class="model-badge">{{ formatModelName(conversation.model) }}</span> <span class="model-badge">{{ formatModelName(conversation.model) }}</span>
<span v-if="conversation.thinking_enabled" class="thinking-badge">思考</span> <span v-if="conversation.thinking_enabled" class="thinking-badge">思考</span>
</div> </div>
<div class="chat-actions"> <div class="chat-actions">
<button class="btn-icon" @click="$emit('toggleStats')" title="使用统计"> <button class="btn-icon" @click="$emit('toggleStats')" title="使用统计">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10"/> <path d="M18 20V10"/>
<path d="M12 20V4"/> <path d="M12 20V4"/>
<path d="M6 20v-6"/> <path d="M6 20v-6"/>
</svg> </svg>
</button> </button>
<button class="btn-icon" @click="$emit('toggleSettings')" title="设置"> <button class="btn-icon" @click="$emit('toggleSettings')" title="设置">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle> <circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg> </svg>
</button> </button>
</div> </div>
</div>
<div ref="scrollContainer" class="messages-container">
<div v-if="hasMoreMessages" class="load-more-top">
<button @click="$emit('loadMoreMessages')" :disabled="loadingMore">
{{ loadingMore ? '加载中...' : '加载更早的消息' }}
</button>
</div> </div>
<div class="messages-list"> <div ref="scrollContainer" class="messages-container">
<MessageBubble <div v-if="hasMoreMessages" class="load-more-top">
v-for="msg in messages" <button @click="$emit('loadMoreMessages')" :disabled="loadingMore">
:key="msg.id" {{ loadingMore ? '加载中...' : '加载更早的消息' }}
:role="msg.role" </button>
:text="msg.text" </div>
:thinking-content="msg.thinking"
:tool-calls="msg.tool_calls"
:process-steps="msg.process_steps"
:token-count="msg.token_count"
:created-at="msg.created_at"
:deletable="msg.role === 'user'"
:attachments="msg.attachments"
@delete="$emit('deleteMessage', msg.id)"
@regenerate="$emit('regenerateMessage', msg.id)"
/>
<div v-if="streaming" class="message-bubble assistant streaming"> <div class="messages-list">
<div class="avatar">claw</div> <div
<div class="message-body"> v-for="msg in messages"
<ProcessBlock :key="msg.id"
:thinking-content="streamingThinking" :data-msg-id="msg.id"
:tool-calls="streamingToolCalls" >
:process-steps="streamingProcessSteps" <MessageBubble
:streaming-content="streamingContent" :role="msg.role"
:streaming="streaming" :text="msg.text"
:thinking-content="msg.thinking"
:tool-calls="msg.tool_calls"
:process-steps="msg.process_steps"
:token-count="msg.token_count"
:created-at="msg.created_at"
:deletable="msg.role === 'user'"
:attachments="msg.attachments"
@delete="$emit('deleteMessage', msg.id)"
@regenerate="$emit('regenerateMessage', msg.id)"
/> />
</div> </div>
<div v-if="streaming" class="message-bubble assistant streaming">
<div class="avatar">claw</div>
<div class="message-body">
<ProcessBlock
:thinking-content="streamingThinking"
:tool-calls="streamingToolCalls"
:process-steps="streamingProcessSteps"
:streaming-content="streamingContent"
:streaming="streaming"
/>
</div>
</div>
</div> </div>
</div> </div>
</div>
<MessageInput <MessageInput
ref="inputRef" ref="inputRef"
:disabled="streaming" :disabled="streaming"
:tools-enabled="toolsEnabled" :tools-enabled="toolsEnabled"
@send="$emit('sendMessage', $event)" @send="$emit('sendMessage', $event)"
@toggle-tools="$emit('toggleTools', $event)" @toggle-tools="$emit('toggleTools', $event)"
/> />
</template> </template>
<MessageNav
v-if="conversation"
:messages="messages"
:active-id="activeMessageId"
@scroll-to="scrollToMessage"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch, nextTick, onMounted } from 'vue' import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import MessageBubble from './MessageBubble.vue' import MessageBubble from './MessageBubble.vue'
import MessageInput from './MessageInput.vue' import MessageInput from './MessageInput.vue'
import MessageNav from './MessageNav.vue'
import ProcessBlock from './ProcessBlock.vue' import ProcessBlock from './ProcessBlock.vue'
import { modelApi } from '../api' import { modelApi } from '../api'
@ -105,6 +117,8 @@ const emit = defineEmits(['sendMessage', 'deleteMessage', 'regenerateMessage', '
const scrollContainer = ref(null) const scrollContainer = ref(null)
const inputRef = ref(null) const inputRef = ref(null)
const modelNameMap = ref({}) const modelNameMap = ref({})
const activeMessageId = ref(null)
let scrollObserver = null
function formatModelName(modelId) { function formatModelName(modelId) {
return modelNameMap.value[modelId] || modelId return modelNameMap.value[modelId] || modelId
@ -121,8 +135,44 @@ onMounted(async () => {
} catch (e) { } catch (e) {
console.warn('Failed to load model names:', e) console.warn('Failed to load model names:', e)
} }
if (scrollContainer.value) {
scrollObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
activeMessageId.value = entry.target.dataset.msgId || null
}
}
},
{ root: scrollContainer.value, threshold: 0.5 }
)
}
}) })
onUnmounted(() => {
scrollObserver?.disconnect()
})
watch(() => props.messages.length, () => {
nextTick(() => {
if (!scrollObserver || !scrollContainer.value) return
const wrappers = scrollContainer.value.querySelectorAll('[data-msg-id]')
wrappers.forEach(el => scrollObserver.observe(el))
})
})
function scrollToMessage(msgId) {
nextTick(() => {
if (!scrollContainer.value) return
const el = scrollContainer.value.querySelector(`[data-msg-id="${msgId}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
activeMessageId.value = msgId
}
})
}
function scrollToBottom(smooth = true) { function scrollToBottom(smooth = true) {
nextTick(() => { nextTick(() => {
const el = scrollContainer.value const el = scrollContainer.value
@ -252,19 +302,12 @@ watch(() => props.conversation?.id, () => {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
scrollbar-width: none;
-ms-overflow-style: none;
} }
.messages-container::-webkit-scrollbar { .messages-container::-webkit-scrollbar {
width: 6px; display: none;
}
.messages-container::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
} }
.load-more-top { .load-more-top {

View File

@ -0,0 +1,127 @@
<template>
<Teleport to="body">
<div v-if="messages.length > 0" class="bookmark-rail">
<div
v-for="(msg, idx) in messages"
:key="msg.id"
class="bookmark"
:class="{ active: activeId === msg.id, user: msg.role === 'user' }"
@click="$emit('scrollTo', msg.id)"
>
<div class="bookmark-dot"></div>
<div class="bookmark-label">{{ msg.role === 'user' ? '用户' : 'Claw' }} · {{ preview(msg) }}</div>
</div>
</div>
</Teleport>
</template>
<script setup>
defineProps({
messages: { type: Array, required: true },
activeId: { type: String, default: null },
})
defineEmits(['scrollTo'])
function preview(msg) {
if (!msg.text) return '...'
const clean = msg.text.replace(/[#*`~>\-\[\]()]/g, '').replace(/\s+/g, ' ').trim()
return clean.length > 60 ? clean.slice(0, 60) + '...' : clean
}
</script>
<style scoped>
.bookmark-rail {
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%);
z-index: 100;
display: flex;
flex-direction: column;
align-items: stretch;
width: 10px;
height: 70vh;
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
transition: width 0.25s ease;
scrollbar-width: none;
-ms-overflow-style: none;
}
.bookmark-rail::-webkit-scrollbar {
display: none;
}
.bookmark-rail:hover {
width: 10vw;
max-width: 160px;
}
.bookmark {
position: relative;
display: flex;
align-items: center;
padding: 5px 6px;
cursor: pointer;
border-radius: 0;
white-space: nowrap;
overflow: hidden;
background: transparent;
transition: background 0.15s;
}
.bookmark:hover {
background: var(--bg-hover);
border-radius: 6px 0 0 6px;
}
.bookmark.active {
background: var(--bg-active);
border-radius: 6px 0 0 6px;
}
.bookmark-dot {
width: 4px;
height: 4px;
border-radius: 50%;
flex-shrink: 0;
background: var(--text-tertiary);
opacity: 0.35;
transition: all 0.2s ease;
}
.bookmark.user .bookmark-dot {
background: #3b82f6;
}
.bookmark.active .bookmark-dot,
.bookmark-rail:hover .bookmark-dot {
opacity: 1;
width: 6px;
height: 6px;
}
.bookmark-label {
margin-left: 8px;
font-size: 12px;
line-height: 1.4;
color: var(--text-secondary);
opacity: 0;
transform: translateX(8px);
transition: all 0.2s ease;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-rail:hover .bookmark-label {
opacity: 1;
transform: translateX(0);
}
.bookmark.active .bookmark-label {
color: var(--text-primary);
}
</style>