Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/renderer/src/components/ChatView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,8 @@ onUnmounted(async () => {
window.electron.ipcRenderer.removeAllListeners(STREAM_EVENTS.END)
window.electron.ipcRenderer.removeAllListeners(STREAM_EVENTS.ERROR)
})

defineExpose({
messageList
})
</script>
253 changes: 253 additions & 0 deletions src/renderer/src/components/MessageNavigationSidebar.vue
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>
Comment on lines +86 to +88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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:

-          <div class="text-sm text-foreground/80 line-clamp-2">
-            <span v-html="highlightSearchQuery(getMessagePreview(message))"></span>
-          </div>
+          <div class="text-sm text-foreground/80 line-clamp-2">
+            <span>
+              <template
+                v-for="(seg, i) in getHighlightedSegments(getMessagePreview(message))"
+                :key="i"
+              >
+                <mark
+                  v-if="seg.m"
+                  class="bg-yellow-200 dark:bg-yellow-800 px-1 rounded"
+                  >{{ seg.t }}</mark
+                >
+                <span v-else>{{ seg.t }}</span>
+              </template>
+            </span>
+          </div>

And replace the highlighter function near Lines 217-229:

-// 高亮搜索关键词
-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>'
-  )
-}
+// 安全高亮(不使用 v-html)
+const getHighlightedSegments = (
+  text: string
+): Array<{ t: string; m: boolean }> => {
+  const q = searchQuery.value.trim()
+  if (!q) return [{ t: text, m: false }]
+  const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+  const regex = new RegExp(`(${escaped})`, 'gi')
+  const parts = text.split(regex).filter(Boolean)
+  const qLower = q.toLowerCase()
+  return parts.map((p) => ({ t: p, m: p.toLowerCase() === qLower }))
+}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/renderer/src/components/MessageNavigationSidebar.vue around lines 86-88,
remove the v-html usage to eliminate XSS risk and instead render highlighted
text as safe DOM nodes: change the template to iterate over an array of text
segments (using v-for) and render each segment with v-text or plain text binding
and conditional classes for highlights rather than using v-html; update the
highlighter function referenced near lines 217-229 to return a safe array of
{text: string, match: boolean} segments (no HTML), splitting the preview on the
search query in a case-insensitive manner and preserving the original text, and
ensure any input is treated as plain text (no innerHTML, no sanitization library
necessary if you never inject HTML). Also update any computed/props usage so the
template consumes the segments array (e.g.,
highlightSearchQuery(getMessagePreview(message)) returns segments) and adjust
types accordingly.

</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>
2 changes: 2 additions & 0 deletions src/renderer/src/components/ThreadsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ const createNewThread = async () => {
try {
await chatStore.createNewEmptyThread()
chatStore.isSidebarOpen = false
chatStore.isMessageNavigationOpen = false
} catch (error) {
console.error(t('common.error.createChatFailed'), error)
}
Expand All @@ -226,6 +227,7 @@ const handleThreadSelect = async (thread: CONVERSATION) => {
await chatStore.setActiveThread(thread.id)
if (windowSize.width.value < 1024) {
chatStore.isSidebarOpen = false
chatStore.isMessageNavigationOpen = false
}
} catch (error) {
console.error(t('common.error.selectChatFailed'), error)
Expand Down
14 changes: 14 additions & 0 deletions src/renderer/src/components/TitleView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@
</div>

<div class="flex items-center gap-2">
<!-- 消息导航按钮 -->
<Button
class="w-7 h-7 rounded-md relative !p-0"
size="icon"
variant="outline"
:class="{ 'bg-accent': chatStore.isMessageNavigationOpen }"
@click="chatStore.isMessageNavigationOpen = !chatStore.isMessageNavigationOpen"
>
<Icon
icon="lucide:list"
class="w-4 h-4 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
/>
</Button>

<ScrollablePopover align="end" content-class="w-80" :enable-scrollable="true">
<template #trigger>
<Button class="w-7 h-7 rounded-md" size="icon" variant="outline">
Expand Down
34 changes: 34 additions & 0 deletions src/renderer/src/components/message/MessageList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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>
🤖 Prompt for AI Agents
In src/renderer/src/components/message/MessageList.vue around lines 398 to 408,
the scoped CSS selectors (.message-highlight and .dark .message-highlight) won't
match elements rendered inside child components (MessageItemUser/Assistant);
update the selectors to use Vue's scoped piercing syntax (e.g.
:deep(.message-highlight)) or scope it under the parent container (e.g.
.message-list :deep(.message-highlight)) and mirror the same change for the dark
variant so the highlight styles apply to child component DOM without leaking
globally.

11 changes: 11 additions & 0 deletions src/renderer/src/i18n/en-US/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,16 @@
"notify": {
"generationComplete": "Generation is complete",
"generationError": "Generation failed"
},
"navigation": {
"title": "Message Navigation",
"searchPlaceholder": "Search messages...",
"noResults": "No matching messages found",
"noMessages": "No messages yet",
"totalMessages": "{count} messages total",
"searchResults": "Found {count} results in {total} messages",
"userMessage": "User message",
"assistantMessage": "Assistant reply",
"unknownMessage": "Unknown message"
}
}
11 changes: 11 additions & 0 deletions src/renderer/src/i18n/fa-IR/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,16 @@
"notify": {
"generationComplete": "ساخت تکمیل شد",
"generationError": "ساخت ناموفق بود"
},
"navigation": {
"title": "ناوبری پیام‌ها",
"searchPlaceholder": "جستجوی پیام‌ها...",
"noResults": "پیام منطبق پیدا نشد",
"noMessages": "هنوز پیامی وجود ندارد",
"totalMessages": "مجموع {count} پیام",
"searchResults": "{count} نتیجه از {total} پیام پیدا شد",
"userMessage": "پیام کاربر",
"assistantMessage": "پاسخ دستیار",
"unknownMessage": "پیام ناشناخته"
}
}
11 changes: 11 additions & 0 deletions src/renderer/src/i18n/fr-FR/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,16 @@
"notify": {
"generationComplete": "La génération est complète",
"generationError": "Échec de la génération"
},
"navigation": {
"title": "Navigation des messages",
"searchPlaceholder": "Rechercher des messages...",
"noResults": "Aucun message correspondant trouvé",
"noMessages": "Aucun message pour le moment",
"totalMessages": "{count} messages au total",
"searchResults": "{count} résultats trouvés sur {total} messages",
"userMessage": "Message utilisateur",
"assistantMessage": "Réponse de l'assistant",
"unknownMessage": "Message inconnu"
}
}
Loading