-
Notifications
You must be signed in to change notification settings - Fork 625
feat: add message navigation sidebar with search functionality #776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,253 @@ | ||
| <template> | ||
| <div | ||
| class="message-navigation-sidebar h-full w-full bg-background border-l border-border flex flex-col" | ||
| > | ||
| <!-- 头部 --> | ||
| <div class="flex items-center justify-between p-4 border-b border-border"> | ||
| <h3 class="text-sm font-medium text-foreground">{{ t('chat.navigation.title') }}</h3> | ||
| <Button variant="ghost" size="icon" class="h-6 w-6" @click="$emit('close')"> | ||
| <Icon icon="lucide:x" class="h-4 w-4" /> | ||
| </Button> | ||
| </div> | ||
|
|
||
| <!-- 搜索框 --> | ||
| <div class="p-4 border-b border-border"> | ||
| <div class="relative"> | ||
| <Icon | ||
| icon="lucide:search" | ||
| class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" | ||
| /> | ||
| <Input | ||
| v-model="searchQuery" | ||
| :placeholder="t('chat.navigation.searchPlaceholder')" | ||
| class="pl-10 pr-8 h-8 text-sm" | ||
| /> | ||
| <Button | ||
| v-if="searchQuery.trim()" | ||
| variant="ghost" | ||
| size="icon" | ||
| class="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6" | ||
| @click="searchQuery = ''" | ||
| > | ||
| <Icon icon="lucide:x" class="h-3 w-3" /> | ||
| </Button> | ||
| </div> | ||
|
|
||
| <!-- 搜索结果计数 --> | ||
| <div v-if="searchQuery.trim()" class="mt-2 text-xs text-muted-foreground"> | ||
| {{ | ||
| t('chat.navigation.searchResults', { | ||
| count: filteredMessages.length, | ||
| total: messages.length | ||
| }) | ||
| }} | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- 消息列表 --> | ||
| <div class="flex-1 overflow-y-auto"> | ||
| <div class="p-2 space-y-1"> | ||
| <div | ||
| v-for="(message, index) in filteredMessages" | ||
| :key="message.id" | ||
| class="message-nav-item p-3 rounded-lg cursor-pointer transition-colors hover:bg-accent/50" | ||
| :class="{ | ||
| 'bg-accent': activeMessageId === message.id, | ||
| 'border-l-2 border-primary': activeMessageId === message.id | ||
| }" | ||
| @click="scrollToMessage(message.id)" | ||
| > | ||
| <div class="flex items-center justify-between mb-2"> | ||
| <div class="flex items-center gap-2"> | ||
| <div class="flex-shrink-0"> | ||
| <div | ||
| v-if="message.role === 'assistant'" | ||
| class="w-4 h-4 flex items-center justify-center bg-base-900/5 dark:bg-base-100/10 border border-input rounded-sm" | ||
| > | ||
| <ModelIcon | ||
| :model-id="message.model_provider || 'default'" | ||
| class="w-2.5 h-2.5" | ||
| :is-dark="themeStore.isDark" | ||
| /> | ||
| </div> | ||
| <div v-else class="w-4 h-4 bg-muted rounded-sm flex items-center justify-center"> | ||
| <Icon icon="lucide:user" class="w-2.5 h-2.5 text-muted-foreground" /> | ||
| </div> | ||
| </div> | ||
|
|
||
| <span class="text-xs text-muted-foreground font-mono"> #{{ index + 1 }} </span> | ||
| </div> | ||
|
|
||
| <span class="text-xs text-muted-foreground"> | ||
| {{ formatTime(message.timestamp) }} | ||
| </span> | ||
| </div> | ||
|
|
||
| <div class="text-sm text-foreground/80 line-clamp-2"> | ||
| <span v-html="highlightSearchQuery(getMessagePreview(message))"></span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div | ||
| v-if="filteredMessages.length === 0" | ||
| class="flex flex-col items-center justify-center h-32 text-muted-foreground" | ||
| > | ||
| <Icon icon="lucide:message-circle" class="h-8 w-8 mb-2" /> | ||
| <p class="text-sm"> | ||
| {{ searchQuery ? t('chat.navigation.noResults') : t('chat.navigation.noMessages') }} | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="p-4 border-t border-border"> | ||
| <div class="text-xs text-muted-foreground text-center"> | ||
| {{ t('chat.navigation.totalMessages', { count: messages.length }) }} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import { ref, computed } from 'vue' | ||
| import { Icon } from '@iconify/vue' | ||
| import { Button } from '@/components/ui/button' | ||
| import { Input } from '@/components/ui/input' | ||
| import { useI18n } from 'vue-i18n' | ||
| import { useThemeStore } from '@/stores/theme' | ||
| import ModelIcon from '@/components/icons/ModelIcon.vue' | ||
| import type { Message } from '@shared/chat' | ||
|
|
||
| interface Props { | ||
| messages: Message[] | ||
| isOpen: boolean | ||
| activeMessageId?: string | ||
| } | ||
|
|
||
| const props = defineProps<Props>() | ||
|
|
||
| const emit = defineEmits<{ | ||
| close: [] | ||
| scrollToMessage: [messageId: string] | ||
| }>() | ||
|
|
||
| const { t } = useI18n() | ||
| const themeStore = useThemeStore() | ||
|
|
||
| const searchQuery = ref('') | ||
|
|
||
| // 获取消息完整内容用于搜索 | ||
| const getFullMessageContent = (message: Message): string => { | ||
| if (message.role === 'user') { | ||
| const userContent = message.content as import('@shared/chat').UserMessageContent | ||
| return userContent.text || '' | ||
| } else if (message.role === 'assistant') { | ||
| const assistantContent = message.content as import('@shared/chat').AssistantMessageBlock[] | ||
| return assistantContent | ||
| .filter((block: import('@shared/chat').AssistantMessageBlock) => block.type === 'content') | ||
| .map((block: import('@shared/chat').AssistantMessageBlock) => block.content || '') | ||
| .join(' ') | ||
| } | ||
| return '' | ||
| } | ||
|
|
||
| // 获取搜索匹配的上下文 | ||
| const getSearchContext = (content: string, query: string, contextLength: number = 30): string => { | ||
| const lowerContent = content.toLowerCase() | ||
| const lowerQuery = query.toLowerCase() | ||
| const matchIndex = lowerContent.indexOf(lowerQuery) | ||
| if (matchIndex === -1) return content.slice(0, 100) | ||
| const start = Math.max(0, matchIndex - contextLength) | ||
| const end = Math.min(content.length, matchIndex + query.length + contextLength) | ||
| let result = content.slice(start, end) | ||
| if (start > 0) result = '...' + result | ||
| if (end < content.length) result = result + '...' | ||
| return result | ||
| } | ||
|
|
||
| // 过滤消息 | ||
| const filteredMessages = computed(() => { | ||
| if (!searchQuery.value.trim()) { | ||
| return props.messages | ||
| } | ||
| const query = searchQuery.value.toLowerCase() | ||
| return props.messages.filter((message) => { | ||
| const fullContent = getFullMessageContent(message).toLowerCase() | ||
| return fullContent.includes(query) | ||
| }) | ||
| }) | ||
|
|
||
| // 获取消息预览文本 | ||
| const getMessagePreview = (message: Message): string => { | ||
| const fullContent = getFullMessageContent(message) | ||
| if (!fullContent) { | ||
| if (message.role === 'user') { | ||
| return t('chat.navigation.userMessage') | ||
| } else if (message.role === 'assistant') { | ||
| return t('chat.navigation.assistantMessage') | ||
| } | ||
| return t('chat.navigation.unknownMessage') | ||
| } | ||
| // 如果有搜索查询,显示搜索上下文 | ||
| if (searchQuery.value.trim()) { | ||
| return getSearchContext(fullContent, searchQuery.value) | ||
| } | ||
| return fullContent.slice(0, 100) + (fullContent.length > 100 ? '...' : '') | ||
| } | ||
|
|
||
| // 格式化时间 | ||
| const formatTime = (timestamp: number): string => { | ||
| const date = new Date(timestamp) | ||
| const now = new Date() | ||
| const diff = now.getTime() - date.getTime() | ||
| if (diff < 24 * 60 * 60 * 1000 && date.getDate() === now.getDate()) { | ||
| return date.toLocaleTimeString('zh-CN', { | ||
| hour: '2-digit', | ||
| minute: '2-digit' | ||
| }) | ||
| } | ||
| return date.toLocaleDateString('zh-CN', { | ||
| month: 'short', | ||
| day: 'numeric', | ||
| hour: '2-digit', | ||
| minute: '2-digit' | ||
| }) | ||
| } | ||
|
|
||
| // 高亮搜索关键词 | ||
| const highlightSearchQuery = (text: string): string => { | ||
| if (!searchQuery.value.trim()) { | ||
| return text | ||
| } | ||
|
|
||
| const query = searchQuery.value.trim() | ||
| const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi') | ||
| return text.replace( | ||
| regex, | ||
| '<mark class="bg-yellow-200 dark:bg-yellow-800 px-1 rounded">$1</mark>' | ||
| ) | ||
| } | ||
|
|
||
| // 滚动到指定消息 | ||
| const scrollToMessage = (messageId: string) => { | ||
| emit('scrollToMessage', messageId) | ||
| } | ||
| </script> | ||
|
|
||
| <style scoped> | ||
| .message-nav-item { | ||
| transition: all 0.2s ease; | ||
| } | ||
|
|
||
| .message-nav-item:hover { | ||
| transform: translateX(2px); | ||
| } | ||
|
|
||
| .line-clamp-2 { | ||
| display: -webkit-box; | ||
| -webkit-line-clamp: 2; | ||
| line-clamp: 2; | ||
| -webkit-box-orient: vertical; | ||
| overflow: hidden; | ||
| } | ||
| </style> | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -275,6 +275,27 @@ const scrollToBottom = () => { | |||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||
| * 滚动到指定消息 | ||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||
| const scrollToMessage = (messageId: string) => { | ||||||||||||||||||||||||||||||||||||||||||||
| nextTick(() => { | ||||||||||||||||||||||||||||||||||||||||||||
| const messageElement = document.querySelector(`[data-message-id="${messageId}"]`) | ||||||||||||||||||||||||||||||||||||||||||||
| if (messageElement) { | ||||||||||||||||||||||||||||||||||||||||||||
| messageElement.scrollIntoView({ | ||||||||||||||||||||||||||||||||||||||||||||
| behavior: 'smooth', | ||||||||||||||||||||||||||||||||||||||||||||
| block: 'center' | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // 添加高亮效果 | ||||||||||||||||||||||||||||||||||||||||||||
| messageElement.classList.add('message-highlight') | ||||||||||||||||||||||||||||||||||||||||||||
| setTimeout(() => { | ||||||||||||||||||||||||||||||||||||||||||||
| messageElement.classList.remove('message-highlight') | ||||||||||||||||||||||||||||||||||||||||||||
| }, 2000) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| onMounted(() => { | ||||||||||||||||||||||||||||||||||||||||||||
| // 获取应用版本 | ||||||||||||||||||||||||||||||||||||||||||||
| devicePresenter.getAppVersion().then((version) => { | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -369,6 +390,19 @@ const createNewThread = async () => { | |||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| defineExpose({ | ||||||||||||||||||||||||||||||||||||||||||||
| scrollToBottom, | ||||||||||||||||||||||||||||||||||||||||||||
| scrollToMessage, | ||||||||||||||||||||||||||||||||||||||||||||
| aboveThreshold | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| </script> | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| <style scoped> | ||||||||||||||||||||||||||||||||||||||||||||
| .message-highlight { | ||||||||||||||||||||||||||||||||||||||||||||
| background-color: rgba(59, 130, 246, 0.1); | ||||||||||||||||||||||||||||||||||||||||||||
| border-left: 3px solid rgb(59, 130, 246); | ||||||||||||||||||||||||||||||||||||||||||||
| transition: all 0.3s ease; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| .dark .message-highlight { | ||||||||||||||||||||||||||||||||||||||||||||
| background-color: rgba(59, 130, 246, 0.15); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| </style> | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+398
to
+408
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scoped style won’t reach child component DOM; use :deep to apply highlight Because this component’s styles are scoped, .message-highlight won’t match elements rendered inside MessageItemUser/Assistant. Use :deep() (scoped piercing) and optionally scope under the container to avoid global bleed. -<style scoped>
-.message-highlight {
- background-color: rgba(59, 130, 246, 0.1);
- border-left: 3px solid rgb(59, 130, 246);
- transition: all 0.3s ease;
-}
-
-.dark .message-highlight {
- background-color: rgba(59, 130, 246, 0.15);
-}
-</style>
+<style scoped>
+.message-list-container :deep(.message-highlight) {
+ background-color: rgba(59, 130, 246, 0.1);
+ border-left: 3px solid rgb(59, 130, 246);
+ transition: all 0.3s ease;
+}
+.dark .message-list-container :deep(.message-highlight) {
+ background-color: rgba(59, 130, 246, 0.15);
+}
+</style>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
XSS risk: v-html on untrusted message text
Using v-html with user/assistant content enables HTML injection. Replace string-based highlighting with node-safe rendering (no v-html).
Apply this diff to render safely:
And replace the highlighter function near Lines 217-229:
🤖 Prompt for AI Agents