feat: 添加右侧悬浮书签导航栏
This commit is contained in:
parent
95e771cb61
commit
8c29f0684f
|
|
@ -38,9 +38,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="messages-list">
|
<div class="messages-list">
|
||||||
<MessageBubble
|
<div
|
||||||
v-for="msg in messages"
|
v-for="msg in messages"
|
||||||
:key="msg.id"
|
:key="msg.id"
|
||||||
|
:data-msg-id="msg.id"
|
||||||
|
>
|
||||||
|
<MessageBubble
|
||||||
:role="msg.role"
|
:role="msg.role"
|
||||||
:text="msg.text"
|
:text="msg.text"
|
||||||
:thinking-content="msg.thinking"
|
:thinking-content="msg.thinking"
|
||||||
|
|
@ -53,6 +56,7 @@
|
||||||
@delete="$emit('deleteMessage', msg.id)"
|
@delete="$emit('deleteMessage', msg.id)"
|
||||||
@regenerate="$emit('regenerateMessage', msg.id)"
|
@regenerate="$emit('regenerateMessage', msg.id)"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="streaming" class="message-bubble assistant streaming">
|
<div v-if="streaming" class="message-bubble assistant streaming">
|
||||||
<div class="avatar">claw</div>
|
<div class="avatar">claw</div>
|
||||||
|
|
@ -77,13 +81,21 @@
|
||||||
@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 {
|
||||||
|
|
|
||||||
|
|
@ -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