feat: 添加右侧悬浮书签导航栏
This commit is contained in:
parent
95e771cb61
commit
8c29f0684f
|
|
@ -38,9 +38,12 @@
|
|||
</div>
|
||||
|
||||
<div class="messages-list">
|
||||
<MessageBubble
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:data-msg-id="msg.id"
|
||||
>
|
||||
<MessageBubble
|
||||
:role="msg.role"
|
||||
:text="msg.text"
|
||||
:thinking-content="msg.thinking"
|
||||
|
|
@ -53,6 +56,7 @@
|
|||
@delete="$emit('deleteMessage', msg.id)"
|
||||
@regenerate="$emit('regenerateMessage', msg.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="streaming" class="message-bubble assistant streaming">
|
||||
<div class="avatar">claw</div>
|
||||
|
|
@ -77,13 +81,21 @@
|
|||
@toggle-tools="$emit('toggleTools', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<MessageNav
|
||||
v-if="conversation"
|
||||
:messages="messages"
|
||||
:active-id="activeMessageId"
|
||||
@scroll-to="scrollToMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, onMounted } from 'vue'
|
||||
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import MessageInput from './MessageInput.vue'
|
||||
import MessageNav from './MessageNav.vue'
|
||||
import ProcessBlock from './ProcessBlock.vue'
|
||||
import { modelApi } from '../api'
|
||||
|
||||
|
|
@ -105,6 +117,8 @@ const emit = defineEmits(['sendMessage', 'deleteMessage', 'regenerateMessage', '
|
|||
const scrollContainer = ref(null)
|
||||
const inputRef = ref(null)
|
||||
const modelNameMap = ref({})
|
||||
const activeMessageId = ref(null)
|
||||
let scrollObserver = null
|
||||
|
||||
function formatModelName(modelId) {
|
||||
return modelNameMap.value[modelId] || modelId
|
||||
|
|
@ -121,8 +135,44 @@ onMounted(async () => {
|
|||
} catch (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) {
|
||||
nextTick(() => {
|
||||
const el = scrollContainer.value
|
||||
|
|
@ -252,19 +302,12 @@ watch(() => props.conversation?.id, () => {
|
|||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.load-more-top {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue