diff --git a/service/package.json b/service/package.json index 18696340..760b1e57 100644 --- a/service/package.json +++ b/service/package.json @@ -28,8 +28,9 @@ "common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml" }, "dependencies": { + "@tavily/core": "^0.5.3", "axios": "^1.8.4", - "dayjs": "^1.11.7", + "dayjs": "^1.11.13", "dotenv": "^16.0.3", "express": "^5.1.0", "express-rate-limit": "^6.7.0", diff --git a/service/pnpm-lock.yaml b/service/pnpm-lock.yaml index e4bf97f4..f56a1dc7 100644 --- a/service/pnpm-lock.yaml +++ b/service/pnpm-lock.yaml @@ -5,12 +5,15 @@ settings: excludeLinksFromLockfile: false dependencies: + '@tavily/core': + specifier: ^0.5.3 + version: 0.5.3 axios: specifier: ^1.8.4 version: 1.8.4 dayjs: - specifier: ^1.11.7 - version: 1.11.7 + specifier: ^1.11.13 + version: 1.11.13 dotenv: specifier: ^16.0.3 version: 16.0.3 @@ -581,6 +584,17 @@ packages: - supports-color dev: true + /@tavily/core@0.5.3: + resolution: {integrity: sha512-KzKkFC/DGC1s1UuNsEbDnTD5ge2UE7Li8hBG69hIxMtkfhuoi5gFu5aBJOYD5gUpSlG7PjkteZc1cR9jNvICJg==} + dependencies: + axios: 1.8.4 + https-proxy-agent: 7.0.6 + js-tiktoken: 1.0.20 + transitivePeerDependencies: + - debug + - supports-color + dev: false + /@tokenizer/token@0.3.0: resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} dev: false @@ -1065,6 +1079,10 @@ packages: resolution: {integrity: sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==} dev: false + /base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + dev: false + /body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -1317,8 +1335,8 @@ packages: engines: {node: '>= 12'} dev: false - /dayjs@1.11.7: - resolution: {integrity: sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==} + /dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} dev: false /debug@3.2.7: @@ -2520,6 +2538,12 @@ packages: '@pkgjs/parseargs': 0.11.0 dev: true + /js-tiktoken@1.0.20: + resolution: {integrity: sha512-Xlaqhhs8VfCd6Sh7a1cFkZHQbYTLCwVJJWiHVxBYzLPxW0XsoxBy1hitmjkdIjD3Aon5BXLHFwU5O8WUx6HH+A==} + dependencies: + base64-js: 1.5.1 + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} dev: true diff --git a/service/src/chatgpt/index.ts b/service/src/chatgpt/index.ts index e8d8a7b7..4b987c81 100644 --- a/service/src/chatgpt/index.ts +++ b/service/src/chatgpt/index.ts @@ -1,6 +1,8 @@ import * as dotenv from 'dotenv' import OpenAI from 'openai' import { HttpsProxyAgent } from 'https-proxy-agent' +import { tavily } from '@tavily/core' +import dayjs from 'dayjs' import type { AuditConfig, KeyConfig, UserInfo } from '../storage/model' import { Status, UsageResponse } from '../storage/model' import { convertImageUrl } from '../utils/image' @@ -10,11 +12,81 @@ import { getCacheApiKeys, getCacheConfig, getOriginConfig } from '../storage/con import { sendResponse } from '../utils' import { hasAnyRole, isNotEmptyString } from '../utils/is' import type { ModelConfig } from '../types' -import { getChatByMessageId, updateRoomChatModel } from '../storage/mongo' +import { getChatByMessageId, updateChatSearchQuery, updateChatSearchResult } from '../storage/mongo' import type { ChatMessage, RequestOptions } from './types' dotenv.config() +function systemMessageWithSearchResult(currentTime: string): string { + return `You are an intelligent assistant that needs to answer user questions based on search results. + +**Search Results Format Description:** +- Search results may contain irrelevant information, please filter and use accordingly + +**Context Information:** +- Current time: ${currentTime} + +**Response Requirements:** + +1. **Content Processing** + - Screen and filter search results, selecting content most relevant to the question + - Synthesize information from multiple web pages, avoiding repetitive citations from a single source + - Do not mention specific sources or rankings of search results + +2. **Response Strategy** + - **Listing questions**: Limit to within 10 key points, prioritize providing the most relevant and complete information + - **Creative questions**: Make full use of search results to generate in-depth professional long-form answers + - **Objective Q&A**: Brief answers may appropriately supplement 1-2 sentences of related information + +3. **Format Requirements** + - Respond using markdown (latex start with $). + - Use structured, paragraph-based answer format + - When answering in points, limit to within 5 points, merging related content + - Ensure answers are aesthetically pleasing and highly readable + +4. **Language Standards** + - Keep answer language consistent with user's question language + - Do not change language unless specifically requested by the user + +**Notes:** +- Not all search results are relevant, need to judge based on the question +- For listing questions, inform users they can check search sources for complete information +- Creative answers need to be multi-perspective, information-rich, and thoroughly discussed` +} + +function systemMessageGetSearchQuery(currentTime: string): string { + return `You are an intelligent search assistant. +Current time: ${currentTime} + +Before formally answering user questions, you need to analyze the user's questions and conversation context to determine whether you need to obtain more information through internet search to provide accurate answers. + +**Task Flow:** +1. Carefully analyze the user's question content and previous conversation history +2. Combined with the current time, determine whether the question involves time-sensitive information +3. Evaluate whether existing knowledge is sufficient to answer the question +4. If search is needed, generate a precise search query +5. If search is not needed, return empty result + +**Output Format Requirements:** +- If search is needed: return example search query keywords +- If search is not needed: return +- Do not include any other explanations or answer content +- Search query should be concise and clear, able to obtain the most relevant information + +**Judgment Criteria:** +- Time-sensitive information (such as latest news, stock prices, weather, real-time data, etc.): search needed +- Latest policies, regulations, technological developments: may need search +- Common sense questions, historical facts, basic knowledge: usually no search needed +- Latest research or developments in professional fields: search recommended + +**Notes:** +- Search query should target the core needs of user questions +- Consider the timeliness and accuracy requirements of information +- Prioritize obtaining the latest and most authoritative information sources + +Please strictly return results according to the above format.` +} + const ErrorCodeMessage: Record = { 401: '[OpenAI] 提供错误的API密钥 | Incorrect API key provided', 403: '[OpenAI] 服务器拒绝访问,请稍后再试 | Server refused to access, please try again later', @@ -49,7 +121,9 @@ export async function initApi(key: KeyConfig) { const processThreads: { userId: string; abort: AbortController; messageId: string }[] = [] async function chatReplyProcess(options: RequestOptions) { + const globalConfig = await getCacheConfig() const model = options.room.chatModel + const searchEnabled = options.room.searchEnabled const key = await getRandomApiKey(options.user, model) const userId = options.user._id.toString() const maxContextCount = options.user.advanced.maxContextCount ?? 20 @@ -57,9 +131,6 @@ async function chatReplyProcess(options: RequestOptions) { if (key == null || key === undefined) throw new Error('没有对应的apikeys配置。请再试一次 | No available apikeys configuration. Please try again.') - // Add Chat Record - updateRoomChatModel(userId, options.room.roomId, model) - const { message, uploadFileKeys, parentMessageId, process, systemMessage, temperature, top_p } = options try { @@ -85,25 +156,7 @@ async function chatReplyProcess(options: RequestOptions) { } // Prepare the user message content (text and images) - let content: string | OpenAI.Chat.ChatCompletionContentPart[] = message - - // Handle image uploads if present - if (uploadFileKeys && uploadFileKeys.length > 0) { - content = [ - { - type: 'text', - text: message, - }, - ] - for (const uploadFileKey of uploadFileKeys) { - content.push({ - type: 'image_url', - image_url: { - url: await convertImageUrl(uploadFileKey), - }, - }) - } - } + const content: string | OpenAI.Chat.ChatCompletionContentPart[] = await createContent(message, uploadFileKeys) // Add the user message messages.push({ @@ -111,6 +164,51 @@ async function chatReplyProcess(options: RequestOptions) { content, }) + let hasSearchResult = false + const searchConfig = globalConfig.searchConfig + if (searchConfig.enabled && searchConfig?.options?.apiKey && searchEnabled) { + messages[0].content = systemMessageGetSearchQuery(dayjs().format('YYYY-MM-DD HH:mm:ss')) + const completion = await openai.chat.completions.create({ + model, + messages, + }) + 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) + + const tvly = tavily({ apiKey: searchConfig.options?.apiKey }) + const response = await tvly.search( + searchQuery, + { + includeRawContent: true, + timeout: 300, + }, + ) + + const searchResult = JSON.stringify(response) + await updateChatSearchResult(messageId, searchResult) + + messages.push({ + role: 'user', + content: `Additional information from web searche engine. +search query: ${searchQuery} +search result: ${searchResult}`, + }) + + messages[0].content = systemMessageWithSearchResult(dayjs().format('YYYY-MM-DD HH:mm:ss')) + hasSearchResult = true + } + } + + if (!hasSearchResult) + messages[0].content = systemMessage + // Create the chat completion with streaming const stream = await openai.chat.completions.create({ model, @@ -244,26 +342,7 @@ async function getMessageById(id: string): Promise { } else { if (isPrompt) { // prompt - let content: string | OpenAI.Chat.ChatCompletionContentPart[] = chatInfo.prompt - if (chatInfo.images && chatInfo.images.length > 0) { - content = [ - { - type: 'text', - text: chatInfo.prompt, - }, - ] - for (const image of chatInfo.images) { - const imageUrlBase64 = await convertImageUrl(image) - if (imageUrlBase64) { - content.push({ - type: 'image_url', - image_url: { - url: await convertImageUrl(image), - }, - }) - } - } - } + const content: string | OpenAI.Chat.ChatCompletionContentPart[] = await createContent(chatInfo.prompt, chatInfo.images) return { id, parentMessageId, @@ -311,6 +390,35 @@ async function getRandomApiKey(user: UserInfo, chatModel: string): Promise { + // If no images or empty array, return just the text + if (!images || images.length === 0) + return text + + // Create content with text and images + const content: OpenAI.Chat.ChatCompletionContentPart[] = [ + { + type: 'text', + text, + }, + ] + + for (const image of images) { + const imageUrl = await convertImageUrl(image) + if (imageUrl) { + content.push({ + type: 'image_url', + image_url: { + url: imageUrl, + }, + }) + } + } + + return content +} + // Helper function to add previous messages to the conversation context async function addPreviousMessages(parentMessageId: string, maxContextCount: number, messages: OpenAI.Chat.ChatCompletionMessageParam[]): Promise { // Recursively get previous messages diff --git a/service/src/index.ts b/service/src/index.ts index 72bebc72..e8bdd09e 100644 --- a/service/src/index.ts +++ b/service/src/index.ts @@ -781,6 +781,33 @@ router.post('/audit-test', rootAuth, async (req, res) => { } }) +router.post('/setting-search', rootAuth, async (req, res) => { + try { + const config = req.body as import('./storage/model').SearchConfig + + const thisConfig = await getOriginConfig() + thisConfig.searchConfig = config + const result = await updateConfig(thisConfig) + clearConfigCache() + res.send({ status: 'Success', message: '操作成功 | Successfully', data: result.searchConfig }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + +router.post('/search-test', rootAuth, async (req, res) => { + try { + const { search, 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: [] } }) + } + catch (error) { + res.send({ status: 'Fail', message: error.message, data: null }) + } +}) + router.post('/setting-advanced', auth, async (req, res) => { try { const config = req.body as { diff --git a/service/src/routes/room.ts b/service/src/routes/room.ts index e2e9e4b4..f3bd7a5e 100644 --- a/service/src/routes/room.ts +++ b/service/src/routes/room.ts @@ -9,6 +9,7 @@ import { renameChatRoom, updateRoomChatModel, updateRoomPrompt, + updateRoomSearchEnabled, updateRoomUsingContext, } from '../storage/mongo' @@ -27,6 +28,7 @@ router.get('/chatrooms', auth, async (req, res) => { prompt: r.prompt, usingContext: r.usingContext === undefined ? true : r.usingContext, chatModel: r.chatModel, + searchEnabled: !!r.searchEnabled, }) }) res.send({ status: 'Success', message: null, data: result }) @@ -135,6 +137,22 @@ router.post('/room-chatmodel', auth, async (req, res) => { } }) +router.post('/room-search-enabled', auth, async (req, res) => { + try { + const userId = req.headers.userId as string + const { searchEnabled, roomId } = req.body as { searchEnabled: boolean; roomId: number } + const success = await updateRoomSearchEnabled(userId, roomId, searchEnabled) + if (success) + res.send({ status: 'Success', message: 'Saved successfully', data: null }) + else + res.send({ status: 'Fail', message: 'Saved Failed', data: null }) + } + catch (error) { + console.error(error) + res.send({ status: 'Fail', message: 'Update error', data: null }) + } +}) + router.post('/room-context', auth, async (req, res) => { try { const userId = req.headers.userId as string diff --git a/service/src/storage/config.ts b/service/src/storage/config.ts index 38cf71a4..c95ffabb 100644 --- a/service/src/storage/config.ts +++ b/service/src/storage/config.ts @@ -2,7 +2,7 @@ import { ObjectId } from 'mongodb' import * as dotenv from 'dotenv' import type { TextAuditServiceProvider } from 'src/utils/textAudit' import { isNotEmptyString, isTextAuditServiceProvider } from '../utils/is' -import { AdvancedConfig, AnnounceConfig, AuditConfig, Config, KeyConfig, MailConfig, SiteConfig, TextAudioType, UserRole } from './model' +import { AdvancedConfig, AnnounceConfig, AuditConfig, Config, KeyConfig, MailConfig, SearchConfig, SiteConfig, TextAudioType, UserRole } from './model' import { getConfig, getKeys, upsertKey } from './mongo' dotenv.config() @@ -110,6 +110,11 @@ export async function getOriginConfig() { ) } + if (!config.searchConfig) { + config.searchConfig = new SearchConfig() + config.searchConfig.enabled = false + } + if (!isNotEmptyString(config.siteConfig.chatModels)) config.siteConfig.chatModels = 'gpt-4.1,gpt-4.1-mini,gpt-4.1-nano' return config diff --git a/service/src/storage/model.ts b/service/src/storage/model.ts index 794110b1..0720db01 100644 --- a/service/src/storage/model.ts +++ b/service/src/storage/model.ts @@ -81,17 +81,16 @@ export class ChatRoom { prompt: string usingContext: boolean status: Status = Status.Normal - // only access token used - accountId?: string chatModel: string - constructor(userId: string, title: string, roomId: number, chatModel: string) { + searchEnabled: boolean + constructor(userId: string, title: string, roomId: number, chatModel: string, searchEnabled: boolean) { this.userId = userId this.title = title this.prompt = undefined this.roomId = roomId this.usingContext = true - this.accountId = null this.chatModel = chatModel + this.searchEnabled = searchEnabled } } @@ -121,6 +120,8 @@ export class ChatInfo { dateTime: number prompt: string images?: string[] + searchQuery?: string + searchResult?: string reasoning?: string response?: string status: Status = Status.Normal @@ -172,6 +173,20 @@ export class ChatUsage { } } +export class SearchConfig { + public enabled: boolean + public provider?: SearchServiceProvider + public options?: SearchServiceOptions +} + +export enum SearchServiceProvider { + Tavily = 'tavily', +} + +export class SearchServiceOptions { + public apiKey: string +} + export class Config { constructor( public _id: ObjectId, @@ -188,6 +203,7 @@ export class Config { public siteConfig?: SiteConfig, public mailConfig?: MailConfig, public auditConfig?: AuditConfig, + public searchConfig?: SearchConfig, public advancedConfig?: AdvancedConfig, public announceConfig?: AnnounceConfig, ) { } diff --git a/service/src/storage/mongo.ts b/service/src/storage/mongo.ts index 54d1a5d0..063a3bb0 100644 --- a/service/src/storage/mongo.ts +++ b/service/src/storage/mongo.ts @@ -115,6 +115,28 @@ export async function updateChat(chatId: string, reasoning: string, response: st await chatCol.updateOne(query, update) } +export async function updateChatSearchQuery(chatId: string, searchQuery: string) { + const query = { _id: new ObjectId(chatId) } + const update = { + $set: { + searchQuery, + }, + } + const result = await chatCol.updateOne(query, update) + return result.modifiedCount > 0 +} + +export async function updateChatSearchResult(chatId: string, searchResult: string) { + const query = { _id: new ObjectId(chatId) } + const update = { + $set: { + searchResult, + }, + } + const result = await chatCol.updateOne(query, update) + return result.modifiedCount > 0 +} + export async function insertChatUsage(userId: ObjectId, roomId: number, chatId: ObjectId, @@ -127,7 +149,7 @@ export async function insertChatUsage(userId: ObjectId, } export async function createChatRoom(userId: string, title: string, roomId: number, chatModel: string) { - const room = new ChatRoom(userId, title, roomId, chatModel) + const room = new ChatRoom(userId, title, roomId, chatModel, false) await roomCol.insertOne(room) return room } @@ -182,6 +204,17 @@ export async function updateRoomChatModel(userId: string, roomId: number, chatMo return result.modifiedCount > 0 } +export async function updateRoomSearchEnabled(userId: string, roomId: number, searchEnabled: boolean) { + const query = { userId, roomId } + const update = { + $set: { + searchEnabled, + }, + } + const result = await roomCol.updateOne(query, update) + return result.modifiedCount > 0 +} + export async function getChatRooms(userId: string) { const cursor = roomCol.find({ userId, status: { $ne: Status.Deleted } }) const rooms = [] diff --git a/src/api/index.ts b/src/api/index.ts index 11757e71..30a69abf 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,6 @@ import type { AxiosProgressEvent, GenericAbortSignal } from 'axios' import { get, post } from '@/utils/request' -import type { AnnounceConfig, AuditConfig, ConfigState, GiftCard, KeyConfig, MailConfig, SiteConfig, Status, UserInfo, UserPassword, UserPrompt } from '@/components/common/Setting/model' +import type { AnnounceConfig, AuditConfig, ConfigState, GiftCard, KeyConfig, MailConfig, SearchConfig, SiteConfig, Status, UserInfo, UserPassword, UserPrompt } from '@/components/common/Setting/model' import { useAuthStore, useUserStore } from '@/store' import type { SettingsState } from '@/store/modules/user/helper' @@ -271,6 +271,13 @@ export function fetchUpdateChatRoomUsingContext(using: boolean, roomId: }) } +export function fetchUpdateChatRoomSearchEnabled(searchEnabled: boolean, roomId: number) { + return post({ + url: '/room-search-enabled', + data: { searchEnabled, roomId }, + }) +} + export function fetchDeleteChatRoom(roomId: number) { return post({ url: '/room-delete', @@ -333,6 +340,20 @@ export function fetchTestAudit(text: string, audit: AuditConfig) { }) } +export function fetchUpdateSearch(search: SearchConfig) { + return post({ + url: '/setting-search', + data: search, + }) +} + +export function fetchTestSearch(text: string, search: SearchConfig) { + return post({ + url: '/search-test', + data: { search, text }, + }) +} + export function fetchUpdateAnnounce(announce: AnnounceConfig) { return post({ url: '/setting-announce', diff --git a/src/components/common/Setting/Search.vue b/src/components/common/Setting/Search.vue new file mode 100644 index 00000000..692fe88c --- /dev/null +++ b/src/components/common/Setting/Search.vue @@ -0,0 +1,121 @@ + + + diff --git a/src/components/common/Setting/index.vue b/src/components/common/Setting/index.vue index b671f6e1..c7a9d554 100644 --- a/src/components/common/Setting/index.vue +++ b/src/components/common/Setting/index.vue @@ -8,6 +8,7 @@ import About from './About.vue' import Site from './Site.vue' import Mail from './Mail.vue' import Audit from './Audit.vue' +import Search from './Search.vue' import Gift from './Gift.vue' import User from './User.vue' import Key from './Keys.vue' @@ -143,6 +144,13 @@ const show = computed({ + + + +