Skip to content
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

Feature/local search enhance #23

Merged
merged 2 commits into from
Feb 25, 2025
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"katex": "^0.16.21",
"lucide-vue-next": "^0.474.0",
"markdown-it": "^14.1.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-mathjax3": "^4.3.2",
"markdown-it-texmath": "^1.0.0",
"marked": "^15.0.7",
Expand Down
6 changes: 6 additions & 0 deletions src/main/presenter/threadPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,12 @@ export class ThreadPresenter implements IThreadPresenter {
return await this.messageManager.getLastUserMessage(conversationId)
}

// 从数据库获取搜索结果
async getSearchResults(messageId: string): Promise<SearchResult[]> {
const results = await this.sqlitePresenter.getMessageAttachments(messageId, 'search_result')
return results.map((result) => JSON.parse(result.content) as SearchResult) ?? []
}

async startStreamCompletion(conversationId: string, queryMsgId?: string) {
const state = Array.from(this.generatingMessages.values()).find(
(state) => state.conversationId === conversationId
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="script-src 'self'; style-src 'self' 'unsafe-inline'"
content="script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
/>
</head>

Expand Down
71 changes: 71 additions & 0 deletions src/renderer/src/components/SearchResultsDrawer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<template>
<Sheet :open="open" @update:open="$emit('update:open', $event)">
<SheetContent
class="h-[80vh] overflow-y-auto p-3 sm:p-6 max-w-[600px] rounded-t-lg mx-auto"
side="bottom"
>
<SheetHeader class="space-y-1">
<SheetTitle class="text-base sm:text-lg">{{ t('chat.search.title') }}</SheetTitle>
<SheetDescription class="text-xs sm:text-sm">
{{ t('chat.search.description', [searchResults.length]) }}
</SheetDescription>
</SheetHeader>
<div class="mt-3 sm:mt-4 space-y-3 sm:space-y-4">
<div
v-for="result in searchResults"
:key="result.url"
class="p-3 sm:p-4 space-y-1.5 sm:space-y-2 rounded-lg border hover:bg-accent/50 active:bg-accent cursor-pointer transition-colors"
@click="openUrl(result.url)"
>
<div class="flex items-center gap-1.5 sm:gap-2">
<img
v-if="result.icon"
:src="result.icon"
class="w-3 h-3 sm:w-4 sm:h-4 rounded"
:alt="result.title"
/>
<Icon v-else icon="lucide:globe" class="w-3 h-3 sm:w-4 sm:h-4" />
<h3 class="font-medium text-xs sm:text-sm line-clamp-1">{{ result.title }}</h3>
</div>
<p class="text-xs sm:text-sm text-muted-foreground line-clamp-2">
{{ result.description || result.content }}
</p>
<div
class="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs text-muted-foreground"
>
<Icon icon="lucide:link" class="w-2.5 h-2.5 sm:w-3 sm:h-3" />
<span class="truncate">{{ result.url }}</span>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Icon } from '@iconify/vue'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet'
import type { SearchResult } from '@shared/presenter'

const { t } = useI18n()

defineProps<{
open: boolean
searchResults: SearchResult[]
}>()

defineEmits<{
'update:open': [value: boolean]
}>()

const openUrl = (url: string) => {
window.open(url, '_blank')
}
</script>
64 changes: 59 additions & 5 deletions src/renderer/src/components/message/MessageBlockContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,16 @@
/>
</template>
<LoadingCursor v-show="block.status === 'loading'" ref="loadingCursor" />
<ReferencePreview :show="showPreview" :content="previewContent" :rect="previewRect" />
</div>
</template>

<script setup lang="ts">
import { computed, ref, nextTick, watch } from 'vue'
import { computed, ref, nextTick, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
createCodeBlockRenderer,
initReference,
renderMarkdown
// enableDebugRendering
} from '@/lib/markdown.helper'
Expand Down Expand Up @@ -67,24 +69,60 @@ import { EditorState } from '@codemirror/state'
import { v4 as uuidv4 } from 'uuid'
import { anysphereTheme } from '@/lib/code.theme'
import LoadingCursor from '@/components/LoadingCursor.vue'

import { usePresenter } from '@/composables/usePresenter'
import { SearchResult } from '@shared/presenter'
import ReferencePreview from './ReferencePreview.vue'

const threadPresenter = usePresenter('threadPresenter')
const searchResults = ref<SearchResult[]>([])

import ArtifactThinking from '../artifacts/ArtifactThinking.vue'
import ArtifactBlock from '../artifacts/ArtifactBlock.vue'
// import mk from '@vscode/markdown-it-katex'
// import 'katex/dist/katex.min.css'

const props = defineProps<{
block: {
content: string
status?: 'loading' | 'success' | 'error'
timestamp: number
}
},
messageId: string
isSearchResult?: boolean
}>()

const id = ref(`editor-${uuidv4()}`)

const loadingCursor = ref<InstanceType<typeof LoadingCursor> | null>(null)
const messageBlock = ref<HTMLDivElement>()

const previewContent = ref<SearchResult | undefined>()
const showPreview = ref(false)
const previewRect = ref<DOMRect>()

const onReferenceClick = (id: string) => {
const index = parseInt(id) - 1
if (searchResults.value && searchResults.value[index]) {
// Handle navigation or content display
// console.log('Navigate to:', searchResults.value[index])
window.open(searchResults.value[index].url, '_blank')
}
}

const onReferenceHover = (id: string, isHover: boolean, rect: DOMRect) => {
const index = parseInt(id) - 1
// console.log(id, isHover, rect)
if (searchResults.value && searchResults.value[index]) {
if (isHover) {
previewContent.value = searchResults.value[index]
previewRect.value = rect
showPreview.value = true
} else {
previewContent.value = undefined
showPreview.value = false
}
}
}

// Store editor instances for cleanup
const editorInstances = ref<Map<string, EditorView>>(new Map())

Expand Down Expand Up @@ -112,6 +150,10 @@ const refreshLoadingCursor = () => {
}
}

initReference({
onClick: onReferenceClick,
onHover: onReferenceHover
})
// Remove all the markdown-it configuration and setup
// Instead, just configure the code block renderer
createCodeBlockRenderer(t)
Expand Down Expand Up @@ -390,7 +432,10 @@ const cleanupEditors = () => {
editorInstances.value.clear()
}

const renderContent = (content: string) => {


const renderedContent = computed(() => {
const content = props.block.content
refreshLoadingCursor()
return renderMarkdown(
props.block.status === 'loading' ? content + loadingCursor.value?.CURSOR_MARKER : content
Expand All @@ -409,6 +454,12 @@ watch(
},
{ immediate: true }
)

onMounted(async () => {
if (props.isSearchResult) {
searchResults.value = await threadPresenter.getSearchResults(props.messageId)
}
})
</script>

<style>
Expand Down Expand Up @@ -490,4 +541,7 @@ watch(
@apply block my-4;
text-align: center;
}
.prose .reference-link {
@apply inline-block text-xs text-muted-foreground bg-muted rounded-md text-center min-w-4 py-0.5 mx-0.5 hover:bg-accent;
}
</style>
18 changes: 17 additions & 1 deletion src/renderer/src/components/message/MessageBlockSearch.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<div
class="inline-flex z-0 flex-row gap-2 items-center cursor-pointer h-9 text-xs text-muted-foreground hover:bg-accent px-2 rounded-md"
@click="openSearchResults"
>
<template v-if="block.status === 'success'">
<div v-if="block.extra.pages" class="flex flex-row ml-1.5">
Expand Down Expand Up @@ -32,14 +33,24 @@
}}</span>
</template>
</div>
<SearchResultsDrawer v-model:open="isDrawerOpen" :search-results="searchResults" />
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Icon } from '@iconify/vue'
import { usePresenter } from '@/composables/usePresenter'
import { SearchResult } from '@shared/presenter'
import { ref } from 'vue'
import SearchResultsDrawer from '../SearchResultsDrawer.vue'

const { t } = useI18n()
const threadPresenter = usePresenter('threadPresenter')
const isDrawerOpen = ref(false)
const searchResults = ref<SearchResult[]>([])

defineProps<{
const props = defineProps<{
messageId: string
block: {
status: 'success' | 'loading'
extra: {
Expand All @@ -51,4 +62,9 @@ defineProps<{
}
}
}>()

const openSearchResults = async () => {
isDrawerOpen.value = true
searchResults.value = await threadPresenter.getSearchResults(props.messageId)
}
</script>
19 changes: 17 additions & 2 deletions src/renderer/src/components/message/MessageItemAssistant.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,22 @@
</div>
<div v-else class="flex flex-col w-full space-y-2">
<div v-for="block in currentContent" :key="block.id" class="w-full">
<MessageBlockContent v-if="block.type === 'content'" :block="block" />
<MessageBlockContent
v-if="block.type === 'content'"
:block="block"
:message-id="message.id"
:is-search-result="isSearchResult"
/>
<MessageBlockThink
v-else-if="block.type === 'reasoning_content'"
:block="block"
:usage="message.usage"
/>
<MessageBlockSearch v-else-if="block.type === 'search'" :block="block" />
<MessageBlockSearch
v-else-if="block.type === 'search'"
:message-id="message.id"
:block="block"
/>
<MessageBlockError v-else-if="block.type === 'error'" :block="block" />
</div>
</div>
Expand Down Expand Up @@ -105,6 +114,12 @@ watch(
}
)

const isSearchResult = computed(() => {
return Boolean(
currentContent.value?.some((block) => block.type === 'search' && block.status === 'success')
)
})

onMounted(() => {
// 默认显示最后一个变体
currentVariantIndex.value = allVariants.value.length
Expand Down
79 changes: 79 additions & 0 deletions src/renderer/src/components/message/ReferencePreview.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<template>
<div
v-if="show"
ref="previewEl"
class="reference-preview fixed z-50 max-w-[384px] bg-popover border rounded-lg shadow-lg p-3 sm:p-4"
:style="positionStyle"
>
<!-- 内容区域 -->
<div class="space-y-1.5 sm:space-y-2">
<!-- 标题区域 -->
<div class="flex items-center gap-1.5 sm:gap-2">
<img
v-if="content?.icon"
:src="content.icon"
class="w-3 h-3 sm:w-4 sm:h-4 rounded"
:alt="content?.title"
/>
<Icon v-else icon="lucide:globe" class="w-3 h-3 sm:w-4 sm:h-4" />
<h3 class="font-medium text-xs sm:text-sm line-clamp-1">{{ content?.title }}</h3>
</div>

<!-- 内容预览 -->
<p class="text-xs sm:text-sm text-muted-foreground line-clamp-2">
{{ content?.description || content?.content }}
</p>

<!-- 链接信息 -->
<div class="flex items-center gap-1.5 text-[10px] sm:text-xs text-muted-foreground">
<Icon icon="lucide:link" class="w-2.5 h-2.5 sm:w-3 sm:h-3" />
<span class="truncate">{{ content?.url }}</span>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { Icon } from '@iconify/vue'
import { SearchResult } from '@shared/presenter'

const props = defineProps<{
show: boolean
content: SearchResult | undefined
rect?: DOMRect
}>()

const previewEl = ref<HTMLElement>()

const positionStyle = computed(() => {
if (!props.rect) return {}

// 获取视窗大小
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight

// 预览框的预计尺寸
const previewWidth = 384 // w-96 = 24rem = 384px
const previewHeight = previewEl.value?.offsetHeight || 200 // 假设最小高度

// 计算基础位置
let top = props.rect.bottom + window.scrollY + 8
let left = props.rect.left + window.scrollX

// 确保不会超出右边界
if (left + previewWidth > viewportWidth) {
left = viewportWidth - previewWidth - 16 // 16px 作为安全边距
}

// 如果底部空间不足,就显示在上方
if (top + previewHeight > viewportHeight + window.scrollY) {
top = props.rect.top + window.scrollY - previewHeight - 8
}

return {
top: `${top}px`,
left: `${left}px`
}
})
</script>
Loading