diff --git a/README.en.md b/README.en.md index a43e2768..213e76df 100644 --- a/README.en.md +++ b/README.en.md @@ -523,6 +523,7 @@ Recommended for current IdP to use OIDC protocol, using [oauth2-proxy](https://o 4. Fill in the following configurations: - **Enable Status**: Turn on/off global search functionality - **API Key**: Enter Tavily API Key + - **Max Search Results**: Set the maximum number of search results returned per search (1-20, default 10) - **Search Query System Message**: Prompt template for extracting search keywords - **Search Result System Message**: Prompt template for processing search results @@ -571,6 +572,7 @@ Current time: {current_time} - **Result Format**: JSON format to store complete search results - **Data Storage**: MongoDB stores search queries and results - **Timeout Setting**: Search request timeout is 300 seconds +- **Result Count Control**: Support configuration of maximum search results returned per search (1-20) ### Notes @@ -579,6 +581,7 @@ Current time: {current_time} - It is recommended to enable selectively based on actual needs - Administrators can control the global search functionality status - Each session can independently control whether to use search functionality +- The maximum search results setting affects the detail level of search and API costs ## Contributing diff --git a/README.md b/README.md index 283fa016..f820ab6e 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,7 @@ pnpm build 4. 填写以下配置: - **启用状态**: 开启/关闭全局搜索功能 - **API Key**: 填入 Tavily API Key + - **最大搜索结果数**: 设置每次搜索返回的最大结果数量(1-20,默认10) - **搜索查询系统消息**: 用于提取搜索关键词的提示模板 - **搜索结果系统消息**: 用于处理搜索结果的提示模板 @@ -452,6 +453,7 @@ Current time: {current_time} - **结果格式**: JSON 格式存储完整搜索结果 - **数据存储**: MongoDB 存储搜索查询和结果 - **超时设置**: 搜索请求超时时间为 300 秒 +- **结果数量控制**: 支持配置每次搜索返回的最大结果数量(1-20) ### 注意事项 @@ -460,6 +462,7 @@ Current time: {current_time} - 建议根据实际需求选择性开启 - 管理员可以控制全局搜索功能的开启状态 - 每个会话可以独立控制是否使用搜索功能 +- 最大搜索结果数设置会影响搜索的详细程度和 API 费用 ## 上下文窗口控制 diff --git a/auto-imports.d.ts b/auto-imports.d.ts index 2d4643fb..e6e93213 100644 --- a/auto-imports.d.ts +++ b/auto-imports.d.ts @@ -69,6 +69,7 @@ declare global { const useCssModule: typeof import('vue')['useCssModule'] const useCssVars: typeof import('vue')['useCssVars'] const useDialog: typeof import('naive-ui')['useDialog'] + const useI18n: typeof import('vue-i18n')['useI18n'] const useId: typeof import('vue')['useId'] const useLink: typeof import('vue-router')['useLink'] const useLoadingBar: typeof import('naive-ui')['useLoadingBar'] diff --git a/components.d.ts b/components.d.ts index 7c0b634f..34310d1a 100644 --- a/components.d.ts +++ b/components.d.ts @@ -18,6 +18,7 @@ declare module 'vue' { NDatePicker: typeof import('naive-ui')['NDatePicker'] NDivider: typeof import('naive-ui')['NDivider'] NDropdown: typeof import('naive-ui')['NDropdown'] + NEllipsis: typeof import('naive-ui')['NEllipsis'] NIcon: typeof import('naive-ui')['NIcon'] NInput: typeof import('naive-ui')['NInput'] NInputNumber: typeof import('naive-ui')['NInputNumber'] diff --git a/package.json b/package.json index 00e501e4..b7d6a185 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "lint-staged": "^16.1.0", "markdown-it": "^14.1.0", "markdown-it-link-attributes": "^4.0.1", - "naive-ui": "^2.41.1", + "naive-ui": "^2.42.0", "pinia": "^3.0.2", "qrcode.vue": "^3.6.0", "rimraf": "^6.0.1", @@ -60,7 +60,7 @@ "vite": "6.3.4", "vue": "^3.5.16", "vue-chartjs": "^5.3.2", - "vue-i18n": "11.1.2", + "vue-i18n": "^11.1.6", "vue-router": "^4.5.1", "vue-tsc": "^2.2.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03ff9885..856dde5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,8 +81,8 @@ devDependencies: specifier: ^4.0.1 version: 4.0.1 naive-ui: - specifier: ^2.41.1 - version: 2.41.1(vue@3.5.16) + specifier: ^2.42.0 + version: 2.42.0(vue@3.5.16) pinia: specifier: ^3.0.2 version: 3.0.2(typescript@5.8.3)(vue@3.5.16) @@ -114,8 +114,8 @@ devDependencies: specifier: ^5.3.2 version: 5.3.2(chart.js@4.4.9)(vue@3.5.16) vue-i18n: - specifier: 11.1.2 - version: 11.1.2(vue@3.5.16) + specifier: ^11.1.6 + version: 11.1.6(vue@3.5.16) vue-router: specifier: ^4.5.1 version: 4.5.1(vue@3.5.16) @@ -951,24 +951,24 @@ packages: vue: 3.5.16(typescript@5.8.3) dev: true - /@intlify/core-base@11.1.2: - resolution: {integrity: sha512-nmG512G8QOABsserleechwHGZxzKSAlggGf9hQX0nltvSwyKNVuB/4o6iFeG2OnjXK253r8p8eSDOZf8PgFdWw==} + /@intlify/core-base@11.1.6: + resolution: {integrity: sha512-gfMLnoWGiQkA1BwK6Qbrog/e3I6Lnkhqk08XObJb0lMq6sLG1Ggl2MazVaMfGnv/E1Td8pCS5UwR54Ys+fOxmQ==} engines: {node: '>= 16'} dependencies: - '@intlify/message-compiler': 11.1.2 - '@intlify/shared': 11.1.2 + '@intlify/message-compiler': 11.1.6 + '@intlify/shared': 11.1.6 dev: true - /@intlify/message-compiler@11.1.2: - resolution: {integrity: sha512-T/xbNDzi+Yv0Qn2Dfz2CWCAJiwNgU5d95EhhAEf4YmOgjCKktpfpiUSmLcBvK1CtLpPQ85AMMQk/2NCcXnNj1g==} + /@intlify/message-compiler@11.1.6: + resolution: {integrity: sha512-w0LYo5sqgQZF3vEmjLlx+5PYk5EEiB+uigsBkka/DKoAIH2c5xlXcjAxhTgSw35Vrck+GOGriahFsfbHL+ZjPw==} engines: {node: '>= 16'} dependencies: - '@intlify/shared': 11.1.2 + '@intlify/shared': 11.1.6 source-map-js: 1.2.1 dev: true - /@intlify/shared@11.1.2: - resolution: {integrity: sha512-dF2iMMy8P9uKVHV/20LA1ulFLL+MKSbfMiixSmn6fpwqzvix38OIc7ebgnFbBqElvghZCW9ACtzKTGKsTGTWGA==} + /@intlify/shared@11.1.6: + resolution: {integrity: sha512-G1Pe4UILhiGOItuehRW+Pk9/NlnRaMFsdnhZ1fwBjiHvrzitmPNZdLx7Eo3GPfRrsk1mdkilZSfgH8SnM419vA==} engines: {node: '>= 16'} dev: true @@ -4441,8 +4441,8 @@ packages: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} dev: true - /naive-ui@2.41.1(vue@3.5.16): - resolution: {integrity: sha512-TRv+GSCHnlbpiTJoz1xS1/l6Vn9/refjzJ6vFfXLuvBkSLB7ow6ERuLf2AQOqUrFSdM542EBJjoK1iM1S6X2lA==} + /naive-ui@2.42.0(vue@3.5.16): + resolution: {integrity: sha512-c7cXR2YgOjgtBadXHwiWL4Y0tpGLAI5W5QzzHksOi22iuHXoSGMAzdkVTGVPE/PM0MSGQ/JtUIzCx2Y0hU0vTQ==} peerDependencies: vue: ^3.0.0 dependencies: @@ -5488,14 +5488,14 @@ packages: - supports-color dev: true - /vue-i18n@11.1.2(vue@3.5.16): - resolution: {integrity: sha512-MfdkdKGUHN+jkkaMT5Zbl4FpRmN7kfelJIwKoUpJ32ONIxdFhzxZiLTVaAXkAwvH3y9GmWpoiwjDqbPIkPIMFA==} + /vue-i18n@11.1.6(vue@3.5.16): + resolution: {integrity: sha512-+IbsW/sTZHj7U1w0rPOYJbuSB0/7DeO1nvUo3BxvO20OQgHs+ukJ3QeLqvoUA6DiLk+8SA9+djRmKC9+FC6cAg==} engines: {node: '>= 16'} peerDependencies: vue: ^3.0.0 dependencies: - '@intlify/core-base': 11.1.2 - '@intlify/shared': 11.1.2 + '@intlify/core-base': 11.1.6 + '@intlify/shared': 11.1.6 '@vue/devtools-api': 6.6.4 vue: 3.5.16(typescript@5.8.3) dev: true diff --git a/service/src/chatgpt/index.ts b/service/src/chatgpt/index.ts index e63dca34..9c788fc3 100644 --- a/service/src/chatgpt/index.ts +++ b/service/src/chatgpt/index.ts @@ -106,69 +106,78 @@ async function chatReplyProcess(options: RequestOptions) { let hasSearchResult = false const searchConfig = globalConfig.searchConfig if (searchConfig.enabled && searchConfig?.options?.apiKey && searchEnabled) { - messages[0].content = renderSystemMessage(searchConfig.systemMessageGetSearchQuery, dayjs().format('YYYY-MM-DD HH:mm:ss')) + try { + messages[0].content = renderSystemMessage(searchConfig.systemMessageGetSearchQuery, dayjs().format('YYYY-MM-DD HH:mm:ss')) - const getSearchQueryChatCompletionCreateBody: OpenAI.ChatCompletionCreateParamsNonStreaming = { - model, - messages, - } - if (key.keyModel === 'VLLM') { - // @ts-expect-error vLLM supports a set of parameters that are not part of the OpenAI API. - getSearchQueryChatCompletionCreateBody.chat_template_kwargs = { - enable_thinking: false, + const getSearchQueryChatCompletionCreateBody: OpenAI.ChatCompletionCreateParamsNonStreaming = { + model, + messages, } - } - const completion = await openai.chat.completions.create(getSearchQueryChatCompletionCreateBody) - let searchQuery: string = completion.choices[0].message.content - const match = searchQuery.match(/([\s\S]*)<\/search_query>/i) - if (match) - searchQuery = match[1].trim() - else - searchQuery = '' - - if (searchQuery) { - await updateChatSearchQuery(messageId, searchQuery) - - process?.({ - searchQuery, - }) - - const tvly = tavily({ apiKey: searchConfig.options?.apiKey }) - const response = await tvly.search( - searchQuery, - { - includeRawContent: true, - // 0 <= x <= 20 https://docs.tavily.com/documentation/api-reference/endpoint/search#body-max-results - maxResults: 20, - // Max 120s, default to 60 https://github.com/tavily-ai/tavily-js/blob/de69e479c5d3f6c5d443465fa2c29407c0d3515d/src/search.ts#L118 - timeout: 120, - }, - ) - - const searchResults = response.results as SearchResult[] - const searchUsageTime = response.responseTime - - await updateChatSearchResult(messageId, searchResults, searchUsageTime) - - process?.({ - searchResults, - searchUsageTime, - }) - - let searchResultContent = JSON.stringify(searchResults) - // remove base64 image content - const base64Pattern = /data:image\/[a-zA-Z0-9+.-]+;base64,[A-Za-z0-9+/=]+/g - searchResultContent = searchResultContent.replace(base64Pattern, '') - - messages.push({ - role: 'user', - content: `Additional information from web searche engine. + if (key.keyModel === 'VLLM') { + // @ts-expect-error vLLM supports a set of parameters that are not part of the OpenAI API. + getSearchQueryChatCompletionCreateBody.chat_template_kwargs = { + enable_thinking: false, + } + } + const completion = await openai.chat.completions.create(getSearchQueryChatCompletionCreateBody) + let searchQuery: string = completion.choices[0].message.content + const match = searchQuery.match(/([\s\S]*)<\/search_query>/i) + if (match) + searchQuery = match[1].trim() + else + searchQuery = '' + + if (searchQuery) { + await updateChatSearchQuery(messageId, searchQuery) + + process?.({ + searchQuery, + }) + + const tvly = tavily({ apiKey: searchConfig.options?.apiKey }) + const response = await tvly.search( + searchQuery, + { + // https://docs.tavily.com/documentation/best-practices/best-practices-search#search-depth%3Dadvanced-ideal-for-higher-relevance-in-search-results + searchDepth: 'advanced', + chunksPerSource: 3, + includeRawContent: true, + // 0 <= x <= 20 https://docs.tavily.com/documentation/api-reference/endpoint/search#body-max-results + // https://docs.tavily.com/documentation/best-practices/best-practices-search#max-results-limiting-the-number-of-results + maxResults: searchConfig.options?.maxResults || 10, + // Max 120s, default to 60 https://github.com/tavily-ai/tavily-js/blob/de69e479c5d3f6c5d443465fa2c29407c0d3515d/src/search.ts#L118 + timeout: 120, + }, + ) + + const searchResults = response.results as SearchResult[] + const searchUsageTime = response.responseTime + + await updateChatSearchResult(messageId, searchResults, searchUsageTime) + + process?.({ + searchResults, + searchUsageTime, + }) + + let searchResultContent = JSON.stringify(searchResults) + // remove image url + const base64Pattern = /!\[([^\]]*)\]\([^)]*\)/g + searchResultContent = searchResultContent.replace(base64Pattern, '$1') + + messages.push({ + role: 'user', + content: `Additional information from web searche engine. search query: ${searchQuery} search result: ${searchResultContent}`, - }) + }) - messages[0].content = renderSystemMessage(searchConfig.systemMessageWithSearchResult, dayjs().format('YYYY-MM-DD HH:mm:ss')) - hasSearchResult = true + messages[0].content = renderSystemMessage(searchConfig.systemMessageWithSearchResult, dayjs().format('YYYY-MM-DD HH:mm:ss')) + hasSearchResult = true + } + } + catch (e) { + globalThis.console.error('search error from tavily, ', e) } } diff --git a/service/src/index.ts b/service/src/index.ts index 76686f96..84109520 100644 --- a/service/src/index.ts +++ b/service/src/index.ts @@ -1,4 +1,4 @@ -import type { AnnounceConfig, AuditConfig, Config, GiftCard, KeyConfig, MailConfig, SiteConfig, UserInfo } from './storage/model' +import type { AnnounceConfig, AuditConfig, Config, GiftCard, KeyConfig, MailConfig, SearchResult, SiteConfig, UserInfo } from './storage/model' import type { AuthJwtPayload } from './types' import * as path from 'node:path' import * as process from 'node:process' @@ -822,13 +822,66 @@ router.post('/setting-search', rootAuth, async (req, res) => { router.post('/search-test', rootAuth, async (req, res) => { try { - const { text } = req.body as { search: import('./storage/model').SearchConfig, text: string } - // TODO: Implement actual search test logic with Tavily API - // For now, just return a success response - res.send({ status: 'Success', message: '搜索测试成功 | Search test successful', data: { query: text, results: [] } }) + const { search, text } = req.body as { search: import('./storage/model').SearchConfig, text: string } + + // Validate search configuration + if (!search.enabled) { + res.send({ status: 'Fail', message: '搜索功能未启用 | Search functionality is not enabled', data: null }) + return + } + + if (!search.options?.apiKey) { + res.send({ status: 'Fail', message: '搜索 API 密钥未配置 | Search API key is not configured', data: null }) + return + } + + if (!text || text.trim() === '') { + res.send({ status: 'Fail', message: '搜索文本不能为空 | Search text cannot be empty', data: null }) + return + } + + // Validate maxResults range + const maxResults = search.options?.maxResults || 10 + if (maxResults < 1 || maxResults > 20) { + res.send({ status: 'Fail', message: '最大搜索结果数必须在 1-20 之间 | Max search results must be between 1-20', data: null }) + return + } + + // Import required modules + const { tavily } = await import('@tavily/core') + + // Execute search + const tvly = tavily({ apiKey: search.options.apiKey }) + const response = await tvly.search( + text.trim(), + { + searchDepth: 'advanced', + chunksPerSource: 3, + includeRawContent: true, + maxResults, + timeout: 120, + }, + ) + + const searchResults = response.results as SearchResult[] + const searchUsageTime = response.responseTime + + // Return search results + res.send({ + status: 'Success', + message: `搜索测试成功 | Search test successful (用时 ${searchUsageTime}ms, 找到 ${searchResults.length} 个结果)`, + data: { + query: text.trim(), + results: searchResults, + usageTime: searchUsageTime, + resultCount: searchResults.length, + maxResults, + }, + }) } - catch (error) { - res.send({ status: 'Fail', message: error.message, data: null }) + catch (error: any) { + console.error('Search test error:', error) + res.send({ status: 'Fail', message: `搜索测试失败 | Search test failed: ${error.message}`, data: null }) } }) diff --git a/service/src/storage/config.ts b/service/src/storage/config.ts index 3e3c2cc9..d8c594ff 100644 --- a/service/src/storage/config.ts +++ b/service/src/storage/config.ts @@ -95,6 +95,7 @@ export async function getOriginConfig() { if (!config.searchConfig) { config.searchConfig = new SearchConfig() config.searchConfig.enabled = false + config.searchConfig.options = { apiKey: '', maxResults: 10 } } if (!isNotEmptyString(config.siteConfig.chatModels)) diff --git a/service/src/storage/model.ts b/service/src/storage/model.ts index be2267d2..d41be992 100644 --- a/service/src/storage/model.ts +++ b/service/src/storage/model.ts @@ -199,6 +199,7 @@ export enum SearchServiceProvider { export class SearchServiceOptions { public apiKey: string + public maxResults?: number } export class Config { diff --git a/src/components/common/PromptStore/index.vue b/src/components/common/PromptStore/index.vue index ce4bdbee..fc5e6ff8 100644 --- a/src/components/common/PromptStore/index.vue +++ b/src/components/common/PromptStore/index.vue @@ -4,16 +4,18 @@ import { NButton } from 'naive-ui' import { fetchClearUserPrompt, fetchDeleteUserPrompt, fetchImportUserPrompt, fetchUpsertUserPrompt, fetchUserPromptList } from '@/api' import { UserPrompt } from '@/components/common/Setting/model' import { useBasicLayout } from '@/hooks/useBasicLayout' -import { t } from '@/locales' import { useAuthStoreWithout, usePromptStore } from '@/store' import { SvgIcon } from '..' import PromptRecommend from '../../../assets/recommend.json' +const props = defineProps() + +const emit = defineEmits() + +const { t } = useI18n() + interface DataProps { _id?: string - renderKey: string - renderValue: string - renderType: string title: string value: string type: 'built-in' | 'user-defined' @@ -27,10 +29,6 @@ interface Emit { (e: 'update:visible', visible: boolean): void } -const props = defineProps() - -const emit = defineEmits() - const message = useMessage() const show = computed({ @@ -277,12 +275,8 @@ async function downloadPromptTemplate() { // 移动端自适应相关 function renderTemplate() { - const [keyLimit, valueLimit] = isMobile.value ? [10, 30] : [15, 50] return promptList.value.map((item: UserPrompt) => { return { - renderKey: item.title.length <= keyLimit ? item.title : `${item.title.substring(0, keyLimit)}...`, - renderValue: item.value.length <= valueLimit ? item.value : `${item.value.substring(0, valueLimit)}...`, - renderType: item.type === 'built-in' ? t('store.builtIn') : t('store.userDefined'), title: item.title, value: item.value, _id: item._id, @@ -304,15 +298,27 @@ function createColumns(): DataTableColumns { return [ { title: 'type', - key: 'renderType', + key: 'type', + width: 100, + align: 'center', + render: (row: DataProps) => row.type === 'built-in' ? t('store.builtIn') : t('store.userDefined'), }, { title: t('store.title'), - key: 'renderKey', + key: 'title', + width: 200, }, { title: t('store.description'), - key: 'renderValue', + key: 'value', + ellipsis: { + lineClamp: 6, + tooltip: { + contentClass: 'whitespace-pre-line text-xs max-h-100 max-w-200', + scrollable: true, + }, + }, + className: 'whitespace-pre-line', }, { title: t('common.action'), @@ -371,7 +377,7 @@ const dataSource = computed(() => { const value = searchValue.value if (value && value !== '') { return data.filter((item: DataProps) => { - return item.renderKey.includes(value) || item.renderValue.includes(value) + return item.title.includes(value) || item.value.includes(value) }) } return data @@ -394,7 +400,7 @@ async function handleGetUserPromptList() {
- +
- {{ $t('common.add') }} + {{ t('common.add') }} - {{ $t('common.import') }} + {{ t('common.import') }} - {{ $t('common.export') }} + {{ t('common.export') }} - {{ $t('store.clearStoreConfirm') }} + {{ t('store.clearStoreConfirm') }}
@@ -445,9 +451,19 @@ async function handleGetUserPromptList() { /> - + + + >