feat: 添加右侧悬浮书签导航栏
This commit is contained in:
parent
95e771cb61
commit
8c29f0684f
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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