diff --git a/README.md b/README.md index ab2d86e61..647d50125 100644 --- a/README.md +++ b/README.md @@ -354,19 +354,28 @@ For ByteDance: use `modelName@bytedance=deploymentName` to customize model name ### `DEEPSEEK_API_KEY` (可选) -DeepSeek Api Key. +DeepSeek Api Key ### `DEEPSEEK_URL` (可选) -DeepSeek Api Url. +DeepSeek Api Url ### `SILICONFLOW_API_KEY` (可选) -硅基流动 API Key. +硅基流动 API Key ### `SILICONFLOW_URL` (可选) -硅基流动 API URL. +硅基流动 API URL + +### `TAVILY_API_KEY` + +Tavily API Key 用于通用搜索功能 +获取地址:https://tavily.com + +### `TAVILY_MAX_RETURNS` (可选) + +通用搜索功能返回的最大结果数,默认为 10 ## 部署 diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 85f31a6cd..3f61a7ca0 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -19,6 +19,7 @@ const DANGER_CONFIG = { isUseOpenAIEndpointForAllModels: serverConfig.isUseOpenAIEndpointForAllModels, disableModelProviderDisplay: serverConfig.disableModelProviderDisplay, isUseRemoteModels: serverConfig.isUseRemoteModels, + isEnableWebSearch: serverConfig.isEnableWebSearch, }; declare global { diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 000000000..310a0c863 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,71 @@ +import { getServerSideConfig } from "@/app/config/server"; +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "../auth"; +import { ModelProvider } from "@/app/constant"; +import { tavily } from "@tavily/core"; + +const serverConfig = getServerSideConfig(); + +async function handle(req: NextRequest) { + const authResult = auth(req, ModelProvider.GPT); + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + try { + const tavilyApiKey = serverConfig.tavilyApiKey; + const maxReturns = serverConfig.tavilyMaxReturns + ? parseInt(serverConfig.tavilyMaxReturns, 10) + : 10; + + if (!tavilyApiKey) { + return NextResponse.json( + { + error: true, + message: "Tavily API key not configured", + }, + { + status: 401, + }, + ); + } + + const body = await req.json(); + const { query } = body; + + if (!query) { + return NextResponse.json( + { + error: true, + message: "Search query is required", + }, + { + status: 400, + }, + ); + } + const tvly = tavily({ apiKey: tavilyApiKey }); + const response = await tvly.search(query, { + maxResults: maxReturns, + }); + + return NextResponse.json(response); + } catch (error) { + console.error("[Tavily] search error:", error); + return NextResponse.json( + { + error: true, + message: "Failed to process search request", + }, + { + status: 500, + }, + ); + } +} + +export const POST = handle; + +export const runtime = "edge"; diff --git a/app/client/api.ts b/app/client/api.ts index 4937d179a..1b265615c 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -25,6 +25,7 @@ import { DeepSeekApi } from "./platforms/deepseek"; import { XAIApi } from "./platforms/xai"; import { ChatGLMApi } from "./platforms/glm"; import { SiliconflowApi } from "./platforms/siliconflow"; +import { TavilySearchResponse } from "@tavily/core"; export const ROLES = ["system", "user", "assistant"] as const; export type MessageRole = (typeof ROLES)[number]; @@ -45,6 +46,7 @@ export interface RequestMessage { role: MessageRole; content: string | MultimodalContent[]; fileInfos?: FileInfo[]; + webSearchReferences?: TavilySearchResponse; } export interface LLMConfig { diff --git a/app/client/platforms/alibaba.ts b/app/client/platforms/alibaba.ts index 2115d5b5a..6f62ae039 100644 --- a/app/client/platforms/alibaba.ts +++ b/app/client/platforms/alibaba.ts @@ -25,7 +25,10 @@ import { } from "@fortaine/fetch-event-source"; import { prettyObject } from "@/app/utils/format"; import { getClientConfig } from "@/app/config/client"; -import { getMessageTextContent } from "@/app/utils"; +import { + getMessageTextContent, + getWebReferenceMessageTextContent, +} from "@/app/utils"; import { fetch } from "@/app/utils/stream"; export interface OpenAIListModelResponse { @@ -104,7 +107,7 @@ export class QwenApi implements LLMApi { async chat(options: ChatOptions) { const messages = options.messages.map((v) => ({ role: v.role, - content: getMessageTextContent(v), + content: getWebReferenceMessageTextContent(v), })); const modelConfig = { diff --git a/app/client/platforms/anthropic.ts b/app/client/platforms/anthropic.ts index c59f76dd3..fa79b24f8 100644 --- a/app/client/platforms/anthropic.ts +++ b/app/client/platforms/anthropic.ts @@ -22,7 +22,11 @@ import { } from "@/app/store"; import { getClientConfig } from "@/app/config/client"; import { ANTHROPIC_BASE_URL } from "@/app/constant"; -import { getMessageTextContent, isVisionModel } from "@/app/utils"; +import { + getMessageTextContent, + getWebReferenceMessageTextContent, + isVisionModel, +} from "@/app/utils"; import { preProcessImageContent, stream } from "@/app/utils/chat"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; import { RequestPayload } from "./openai"; @@ -318,7 +322,7 @@ export class ClaudeApi implements LLMApi { if (!visionModel || typeof content === "string") { return { role: insideRole, - content: getMessageTextContent(v), + content: getWebReferenceMessageTextContent(v), }; } return { diff --git a/app/client/platforms/baidu.ts b/app/client/platforms/baidu.ts index 96c94f9a0..3132890e4 100644 --- a/app/client/platforms/baidu.ts +++ b/app/client/platforms/baidu.ts @@ -26,7 +26,10 @@ import { } from "@fortaine/fetch-event-source"; import { prettyObject } from "@/app/utils/format"; import { getClientConfig } from "@/app/config/client"; -import { getMessageTextContent } from "@/app/utils"; +import { + getMessageTextContent, + getWebReferenceMessageTextContent, +} from "@/app/utils"; import { fetch } from "@/app/utils/stream"; export interface OpenAIListModelResponse { @@ -97,7 +100,7 @@ export class ErnieApi implements LLMApi { const messages = options.messages.map((v) => ({ // "error_code": 336006, "error_msg": "the role of message with even index in the messages must be user or function", role: v.role === "system" ? "user" : v.role, - content: getMessageTextContent(v), + content: getWebReferenceMessageTextContent(v), })); // "error_code": 336006, "error_msg": "the length of messages must be an odd number", diff --git a/app/client/platforms/bytedance.ts b/app/client/platforms/bytedance.ts index 7b04b66e3..3ecf4d3ec 100644 --- a/app/client/platforms/bytedance.ts +++ b/app/client/platforms/bytedance.ts @@ -25,7 +25,10 @@ import { } from "@fortaine/fetch-event-source"; import { prettyObject } from "@/app/utils/format"; import { getClientConfig } from "@/app/config/client"; -import { getMessageTextContent } from "@/app/utils"; +import { + getMessageTextContent, + getWebReferenceMessageTextContent, +} from "@/app/utils"; import { fetch } from "@/app/utils/stream"; export interface OpenAIListModelResponse { @@ -98,7 +101,7 @@ export class DoubaoApi implements LLMApi { async chat(options: ChatOptions) { const messages = options.messages.map((v) => ({ role: v.role, - content: getMessageTextContent(v), + content: getWebReferenceMessageTextContent(v), })); const modelConfig = { diff --git a/app/client/platforms/deepseek.ts b/app/client/platforms/deepseek.ts index 324bfb5c7..e000117c3 100644 --- a/app/client/platforms/deepseek.ts +++ b/app/client/platforms/deepseek.ts @@ -28,6 +28,7 @@ import { getClientConfig } from "@/app/config/client"; import { getMessageTextContent, getMessageTextContentWithoutThinking, + getWebReferenceMessageTextContent, } from "@/app/utils"; import { RequestPayload } from "./openai"; import { fetch } from "@/app/utils/stream"; @@ -86,7 +87,7 @@ export class DeepSeekApi implements LLMApi { const content = getMessageTextContentWithoutThinking(v); messages.push({ role: v.role, content }); } else { - const content = getMessageTextContent(v); + const content = getWebReferenceMessageTextContent(v); messages.push({ role: v.role, content }); } } diff --git a/app/client/platforms/glm.ts b/app/client/platforms/glm.ts index 6c83b9267..ee1c7c1bf 100644 --- a/app/client/platforms/glm.ts +++ b/app/client/platforms/glm.ts @@ -24,7 +24,10 @@ import { TranscriptionOptions, } from "../api"; import { getClientConfig } from "@/app/config/client"; -import { getMessageTextContent } from "@/app/utils"; +import { + getMessageTextContent, + getWebReferenceMessageTextContent, +} from "@/app/utils"; import { RequestPayload } from "./openai"; import { fetch } from "@/app/utils/stream"; @@ -78,7 +81,7 @@ export class ChatGLMApi implements LLMApi { async chat(options: ChatOptions) { const messages: ChatOptions["messages"] = []; for (const v of options.messages) { - const content = getMessageTextContent(v); + const content = getWebReferenceMessageTextContent(v); messages.push({ role: v.role, content }); } diff --git a/app/client/platforms/google.ts b/app/client/platforms/google.ts index 647ba61a0..ed341a98c 100644 --- a/app/client/platforms/google.ts +++ b/app/client/platforms/google.ts @@ -25,6 +25,7 @@ import { getMessageTextContent, getMessageImages, isVisionModel, + getWebReferenceMessageTextContent, } from "@/app/utils"; import { preProcessImageContent } from "@/app/utils/chat"; import { nanoid } from "nanoid"; @@ -91,7 +92,7 @@ export class GeminiProApi implements LLMApi { _messages.push({ role: v.role, content }); } const messages = _messages.map((v) => { - let parts: any[] = [{ text: getMessageTextContent(v) }]; + let parts: any[] = [{ text: getWebReferenceMessageTextContent(v) }]; if (isVisionModel(options.config.model)) { const images = getMessageImages(v); if (images.length > 0) { diff --git a/app/client/platforms/iflytek.ts b/app/client/platforms/iflytek.ts index 75a856aed..939d22b98 100644 --- a/app/client/platforms/iflytek.ts +++ b/app/client/platforms/iflytek.ts @@ -24,7 +24,10 @@ import { } from "@fortaine/fetch-event-source"; import { prettyObject } from "@/app/utils/format"; import { getClientConfig } from "@/app/config/client"; -import { getMessageTextContent } from "@/app/utils"; +import { + getMessageTextContent, + getWebReferenceMessageTextContent, +} from "@/app/utils"; import { fetch } from "@/app/utils/stream"; import { RequestPayload } from "./openai"; @@ -79,7 +82,7 @@ export class SparkApi implements LLMApi { async chat(options: ChatOptions) { const messages: ChatOptions["messages"] = []; for (const v of options.messages) { - const content = getMessageTextContent(v); + const content = getWebReferenceMessageTextContent(v); messages.push({ role: v.role, content }); } diff --git a/app/client/platforms/moonshot.ts b/app/client/platforms/moonshot.ts index 952042572..6da865fc2 100644 --- a/app/client/platforms/moonshot.ts +++ b/app/client/platforms/moonshot.ts @@ -25,7 +25,10 @@ import { TranscriptionOptions, } from "../api"; import { getClientConfig } from "@/app/config/client"; -import { getMessageTextContent } from "@/app/utils"; +import { + getMessageTextContent, + getWebReferenceMessageTextContent, +} from "@/app/utils"; import { RequestPayload } from "./openai"; import { fetch } from "@/app/utils/stream"; @@ -79,7 +82,7 @@ export class MoonshotApi implements LLMApi { async chat(options: ChatOptions) { const messages: ChatOptions["messages"] = []; for (const v of options.messages) { - const content = getMessageTextContent(v); + const content = getWebReferenceMessageTextContent(v); messages.push({ role: v.role, content }); } diff --git a/app/client/platforms/openai.ts b/app/client/platforms/openai.ts index c1b24cbe2..cec3e383a 100644 --- a/app/client/platforms/openai.ts +++ b/app/client/platforms/openai.ts @@ -44,6 +44,7 @@ import { getMessageTextContent, isVisionModel, isDalle3 as _isDalle3, + getWebReferenceMessageTextContent, } from "@/app/utils"; export interface OpenAIListModelResponse { @@ -239,7 +240,7 @@ export class ChatGPTApi implements LLMApi { for (const v of options.messages) { const content = visionModel ? await preProcessImageContent(v.content) - : getMessageTextContent(v); + : getWebReferenceMessageTextContent(v); if (!(isO1 && v.role === "system")) messages.push({ role: v.role, content }); } diff --git a/app/client/platforms/siliconflow.ts b/app/client/platforms/siliconflow.ts index 6f71b51f5..558d48a6b 100644 --- a/app/client/platforms/siliconflow.ts +++ b/app/client/platforms/siliconflow.ts @@ -28,6 +28,7 @@ import { getClientConfig } from "@/app/config/client"; import { getMessageTextContent, getMessageTextContentWithoutThinking, + getWebReferenceMessageTextContent, } from "@/app/utils"; import { RequestPayload } from "./openai"; import { fetch } from "@/app/utils/stream"; @@ -89,7 +90,7 @@ export class SiliconflowApi implements LLMApi { const content = getMessageTextContentWithoutThinking(v); messages.push({ role: v.role, content }); } else { - const content = getMessageTextContent(v); + const content = getWebReferenceMessageTextContent(v); messages.push({ role: v.role, content }); } } diff --git a/app/client/platforms/tencent.ts b/app/client/platforms/tencent.ts index 2fec3e940..40ab4d637 100644 --- a/app/client/platforms/tencent.ts +++ b/app/client/platforms/tencent.ts @@ -20,7 +20,11 @@ import { } from "@fortaine/fetch-event-source"; import { prettyObject } from "@/app/utils/format"; import { getClientConfig } from "@/app/config/client"; -import { getMessageTextContent, isVisionModel } from "@/app/utils"; +import { + getMessageTextContent, + getWebReferenceMessageTextContent, + isVisionModel, +} from "@/app/utils"; import mapKeys from "lodash-es/mapKeys"; import mapValues from "lodash-es/mapValues"; import isArray from "lodash-es/isArray"; @@ -110,7 +114,7 @@ export class HunyuanApi implements LLMApi { const messages = options.messages.map((v, index) => ({ // "Messages 中 system 角色必须位于列表的最开始" role: index !== 0 && v.role === "system" ? "user" : v.role, - content: visionModel ? v.content : getMessageTextContent(v), + content: visionModel ? v.content : getWebReferenceMessageTextContent(v), })); const modelConfig = { diff --git a/app/client/platforms/utils.ts b/app/client/platforms/utils.ts index c8f7ade66..48a4e3972 100644 --- a/app/client/platforms/utils.ts +++ b/app/client/platforms/utils.ts @@ -1,3 +1,4 @@ +import { TavilySearchResponse } from "@tavily/core"; import { ClientApi, getClientApi, getHeaders } from "../api"; import { ChatSession } from "@/app/store"; @@ -45,3 +46,19 @@ export class FileApi { return fileInfo; } } + +export class WebApi { + async search(query: string): Promise { + var headers = getHeaders(true); + const api = "/api/search"; + var res = await fetch(api, { + method: "POST", + body: JSON.stringify({ query }), + headers: { + ...headers, + }, + }); + const resJson = await res.json(); + return resJson; + } +} diff --git a/app/client/platforms/xai.ts b/app/client/platforms/xai.ts index 8e3716f66..3cb9dd361 100644 --- a/app/client/platforms/xai.ts +++ b/app/client/platforms/xai.ts @@ -20,7 +20,10 @@ import { TranscriptionOptions, } from "../api"; import { getClientConfig } from "@/app/config/client"; -import { getMessageTextContent } from "@/app/utils"; +import { + getMessageTextContent, + getWebReferenceMessageTextContent, +} from "@/app/utils"; import { RequestPayload } from "./openai"; import { fetch } from "@/app/utils/stream"; @@ -74,7 +77,7 @@ export class XAIApi implements LLMApi { async chat(options: ChatOptions) { const messages: ChatOptions["messages"] = []; for (const v of options.messages) { - const content = getMessageTextContent(v); + const content = getWebReferenceMessageTextContent(v); messages.push({ role: v.role, content }); } diff --git a/app/components/chat.tsx b/app/components/chat.tsx index f6e4843bf..e8e24b397 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -52,6 +52,8 @@ import PluginIcon from "../icons/plugin.svg"; import ShortcutkeyIcon from "../icons/shortcutkey.svg"; import ReloadIcon from "../icons/reload.svg"; import HeadphoneIcon from "../icons/headphone.svg"; +import SearchCloseIcon from "../icons/search_close.svg"; +import SearchOpenIcon from "../icons/search_open.svg"; import { ChatMessage, SubmitKey, @@ -509,6 +511,17 @@ export function ChatActions(props: { const pluginStore = usePluginStore(); const session = chatStore.currentSession(); + // switch web search + const webSearch = chatStore.currentSession().mask.webSearch; + function switchWebSearch() { + chatStore.updateTargetSession(session, (session) => { + session.mask.webSearch = + !session.mask.webSearch && + !isFunctionCallModel(currentModel) && + isEnableWebSearch; + }); + } + // switch Plugins const usePlugins = chatStore.currentSession().mask.usePlugins; function switchUsePlugins() { @@ -593,6 +606,11 @@ export function ChatActions(props: { // eslint-disable-next-line react-hooks/exhaustive-deps [], ); + const isEnableWebSearch = useMemo( + () => accessStore.enableWebSearch(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); useEffect(() => { const show = isVisionModel(currentModel); @@ -723,6 +741,17 @@ export function ChatActions(props: { text={currentModelName} icon={} /> + {!isFunctionCallModel(currentModel) && isEnableWebSearch && ( + : } + /> + )} {showModelSelector && ( setIsLoading(false)); inputRef.current?.focus(); }; @@ -1432,34 +1466,36 @@ function _Chat() { // preview messages const renderMessages = useMemo(() => { - return context - .concat(session.messages as RenderMessage[]) - .concat( - isLoading - ? [ - { - ...createMessage({ - role: "assistant", - content: "……", - }), - preview: true, - }, - ] - : [], - ) - .concat( - userInput.length > 0 && config.sendPreviewBubble - ? [ - { - ...createMessage({ - role: "user", - content: userInput, - }), - preview: true, - }, - ] - : [], - ); + return ( + context + .concat(session.messages as RenderMessage[]) + // .concat( + // isLoading + // ? [ + // { + // ...createMessage({ + // role: "assistant", + // content: "……", + // }), + // preview: true, + // }, + // ] + // : [], + // ) + .concat( + userInput.length > 0 && config.sendPreviewBubble + ? [ + { + ...createMessage({ + role: "user", + content: userInput, + }), + preview: true, + }, + ] + : [], + ) + ); }, [ config.sendPreviewBubble, context, @@ -2093,6 +2129,7 @@ function _Chat() { )} - {message?.audio_url && ( + {message?.audioUrl && (
-
)} diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx index a047f63ec..3681967f0 100644 --- a/app/components/markdown.tsx +++ b/app/components/markdown.tsx @@ -23,6 +23,7 @@ import { useChatStore } from "../store"; import { IconButton } from "./button"; import { useAppConfig } from "../store/config"; +import { TavilySearchResponse } from "@tavily/core"; export function Mermaid(props: { code: string }) { const ref = useRef(null); @@ -273,10 +274,20 @@ function tryWrapHtmlCode(text: string) { ); } -function _MarkDownContent(props: { content: string }) { +function _MarkDownContent(props: { + content: string; + webSearchReferences?: TavilySearchResponse; +}) { const escapedContent = useMemo(() => { - return tryWrapHtmlCode(escapeBrackets(props.content)); - }, [props.content]); + let content = tryWrapHtmlCode(escapeBrackets(props.content)); + if (props.webSearchReferences?.results) { + content = content.replace(/\[citation:(\d+)\]/g, (match, index) => { + const result = props.webSearchReferences?.results[parseInt(index) - 1]; + return result ? `[\[${index}\]](${result.url})` : match; + }); + } + return content; + }, [props.content, props.webSearchReferences]); return ( ; defaultShow?: boolean; + webSearchReferences?: TavilySearchResponse; } & React.DOMAttributes, ) { const mdRef = useRef(null); @@ -351,7 +363,10 @@ export function Markdown( {props.loading ? ( ) : ( - + )} ); diff --git a/app/components/realtime-chat/realtime-chat.tsx b/app/components/realtime-chat/realtime-chat.tsx index ffbab0f3b..2337c79ea 100644 --- a/app/components/realtime-chat/realtime-chat.tsx +++ b/app/components/realtime-chat/realtime-chat.tsx @@ -191,11 +191,11 @@ export function RealtimeChat({ }); } if (hasAudio) { - // upload audio get audio_url + // upload audio get audioUrl const blob = audioHandlerRef.current?.savePlayFile(); - uploadImage(blob!).then((audio_url) => { - botMessage.audio_url = audio_url; - // update text and audio_url + uploadImage(blob!).then((audioUrl) => { + botMessage.audioUrl = audioUrl; + // update text and audioUrl chatStore.updateTargetSession(session, (session) => { session.messages = session.messages.concat(); }); @@ -215,15 +215,15 @@ export function RealtimeChat({ chatStore.updateTargetSession(session, (session) => { session.messages = session.messages.concat([userMessage]); }); - // save input audio_url, and update session + // save input audioUrl, and update session const { audioStartMillis, audioEndMillis } = item; - // upload audio get audio_url + // upload audio get audioUrl const blob = audioHandlerRef.current?.saveRecordFile( audioStartMillis, audioEndMillis, ); - uploadImage(blob!).then((audio_url) => { - userMessage.audio_url = audio_url; + uploadImage(blob!).then((audioUrl) => { + userMessage.audioUrl = audioUrl; chatStore.updateTargetSession(session, (session) => { session.messages = session.messages.concat(); }); diff --git a/app/config/server.ts b/app/config/server.ts index a3358ff33..7a6686451 100644 --- a/app/config/server.ts +++ b/app/config/server.ts @@ -285,5 +285,9 @@ export const getServerSideConfig = () => { disableModelProviderDisplay: !!process.env.DISABLE_MODEL_PROVIDER_DISPLAY, isUseRemoteModels: !!process.env.USE_REMOTE_MODELS, + + tavilyApiKey: process.env.TAVILY_API_KEY, + tavilyMaxReturns: process.env.TAVILY_MAX_RETURNS ?? "10", + isEnableWebSearch: !!process.env.TAVILY_API_KEY, }; }; diff --git a/app/constant.ts b/app/constant.ts index 4d1eb2050..2f3a12e9e 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -662,30 +662,3 @@ export const internalAllowedWebDavEndpoints = [ ]; export const DEFAULT_GA_ID = "G-89WN60ZK2E"; - -export const MYFILES_BROWSER_TOOLS_SYSTEM_PROMPT = ` -# Tools - -## myfiles_browser - -You have the tool 'myfiles_browser' with the following functions: -issues queries to search the file(s) uploaded in the current conversation and displays the results. - -This tool is for browsing the files uploaded by the user. - -Parts of the documents uploaded by users will be automatically included in the conversation. Only use this tool when the relevant parts don't contain the necessary information to fulfill the user's request. - -If the user needs to summarize the document, they can summarize it through parts of the document. - -Think carefully about how the information you find relates to the user's request. Respond as soon as you find information that clearly answers the request. - -Issue multiple queries to the 'myfiles_browser' command only when the user's question needs to be decomposed to find different facts. In other scenarios, prefer providing a single query. Avoid single-word queries that are extremely broad and will return unrelated results. - -Here are some examples of how to use the 'myfiles_browser' command: -User: What was the GDP of France and Italy in the 1970s? => myfiles_browser(["france gdp 1970", "italy gdp 1970"]) -User: What does the report say about the GPT4 performance on MMLU? => myfiles_browser(["GPT4 MMLU performance"]) -User: How can I integrate customer relationship management system with third-party email marketing tools? => myfiles_browser(["customer management system marketing integration"]) -User: What are the best practices for data security and privacy for our cloud storage services? => myfiles_browser(["cloud storage security and privacy"]) - -The user has uploaded the following files: -`; diff --git a/app/icons/search_close.svg b/app/icons/search_close.svg new file mode 100644 index 000000000..bd819285d --- /dev/null +++ b/app/icons/search_close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/icons/search_open.svg b/app/icons/search_open.svg new file mode 100644 index 000000000..7dce1700e --- /dev/null +++ b/app/icons/search_open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 4b9230dae..7141c0632 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -73,9 +73,12 @@ const cn = { DisablePlugins: "关闭插件", UploadImage: "上传图片", UploadFle: "上传文件", + OpenWebSearch: "开启联网", + CloseWebSearch: "关闭联网", }, Rename: "重命名对话", Typing: "正在输入…", + Searching: "联网搜索中…", Input: (submitKey: string) => { var inputHints = `${submitKey} 发送`; if (submitKey === String(SubmitKey.Enter)) { diff --git a/app/locales/en.ts b/app/locales/en.ts index 0b8c25d20..c2468a6b8 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -75,9 +75,12 @@ const en: LocaleType = { DisablePlugins: "Disable Plugins", UploadImage: "Upload Images", UploadFle: "Upload Files", + OpenWebSearch: "Enable Web Search", + CloseWebSearch: "Disable Web Search", }, Rename: "Rename Chat", Typing: "Typing…", + Searching: "Searching…", Input: (submitKey: string) => { var inputHints = `${submitKey} to send`; if (submitKey === String(SubmitKey.Enter)) { diff --git a/app/prompt.ts b/app/prompt.ts new file mode 100644 index 000000000..8a3eb738a --- /dev/null +++ b/app/prompt.ts @@ -0,0 +1,60 @@ +export const MYFILES_BROWSER_TOOLS_SYSTEM_PROMPT = ` +# Tools + +## myfiles_browser + +You have the tool 'myfiles_browser' with the following functions: +issues queries to search the file(s) uploaded in the current conversation and displays the results. + +This tool is for browsing the files uploaded by the user. + +Parts of the documents uploaded by users will be automatically included in the conversation. Only use this tool when the relevant parts don't contain the necessary information to fulfill the user's request. + +If the user needs to summarize the document, they can summarize it through parts of the document. + +Think carefully about how the information you find relates to the user's request. Respond as soon as you find information that clearly answers the request. + +Issue multiple queries to the 'myfiles_browser' command only when the user's question needs to be decomposed to find different facts. In other scenarios, prefer providing a single query. Avoid single-word queries that are extremely broad and will return unrelated results. + +Here are some examples of how to use the 'myfiles_browser' command: +User: What was the GDP of France and Italy in the 1970s? => myfiles_browser(["france gdp 1970", "italy gdp 1970"]) +User: What does the report say about the GPT4 performance on MMLU? => myfiles_browser(["GPT4 MMLU performance"]) +User: How can I integrate customer relationship management system with third-party email marketing tools? => myfiles_browser(["customer management system marketing integration"]) +User: What are the best practices for data security and privacy for our cloud storage services? => myfiles_browser(["cloud storage security and privacy"]) + +The user has uploaded the following files: +`; + +export const WEB_SEARCH_ANSWER_ZH_PROMPT = `# 以下内容是基于用户发送的消息的搜索结果: +{search_results} +在我给你的搜索结果中,每个结果都是[webpage X begin]...[webpage X end]格式的,X代表每篇文章的数字索引。请在适当的情况下在句子末尾引用上下文。请按照引用编号[citation:X]的格式在答案中对应部分引用上下文。如果一句话源自多个上下文,请列出所有相关的引用编号,例如[citation:3][citation:5],切记不要将引用集中在最后返回引用编号,而是在答案对应部分列出。 +在回答时,请注意以下几点: +- 今天是{cur_date}。 +- 并非搜索结果的所有内容都与用户的问题密切相关,你需要结合问题,对搜索结果进行甄别、筛选。 +- 对于列举类的问题(如列举所有航班信息),尽量将答案控制在10个要点以内,并告诉用户可以查看搜索来源、获得完整信息。优先提供信息完整、最相关的列举项;如非必要,不要主动告诉用户搜索结果未提供的内容。 +- 对于创作类的问题(如写论文),请务必在正文的段落中引用对应的参考编号,例如[citation:3][citation:5],不能只在文章末尾引用。你需要解读并概括用户的题目要求,选择合适的格式,充分利用搜索结果并抽取重要信息,生成符合用户要求、极具思想深度、富有创造力与专业性的答案。你的创作篇幅需要尽可能延长,对于每一个要点的论述要推测用户的意图,给出尽可能多角度的回答要点,且务必信息量大、论述详尽。 +- 如果回答很长,请尽量结构化、分段落总结。如果需要分点作答,尽量控制在5个点以内,并合并相关的内容。 +- 对于客观类的问答,如果问题的答案非常简短,可以适当补充一到两句相关信息,以丰富内容。 +- 你需要根据用户要求和回答内容选择合适、美观的回答格式,确保可读性强。 +- 你的回答应该综合多个相关网页来回答,不能重复引用一个网页。 +- 除非用户要求,否则你回答的语言需要和用户提问的语言保持一致。 + +# 用户消息为: +{question}`; + +export const WEB_SEARCH_ANSWER_EN_PROMPT = `# The following contents are the search results related to the user's message: +{search_results} +In the search results I provide to you, each result is formatted as [webpage X begin]...[webpage X end], where X represents the numerical index of each article. Please cite the context at the end of the relevant sentence when appropriate. Use the citation format [citation:X] in the corresponding part of your answer. If a sentence is derived from multiple contexts, list all relevant citation numbers, such as [citation:3][citation:5]. Be sure not to cluster all citations at the end; instead, include them in the corresponding parts of the answer. +When responding, please keep the following points in mind: +- Today is {cur_date}. +- Not all content in the search results is closely related to the user's question. You need to evaluate and filter the search results based on the question. +- For listing-type questions (e.g., listing all flight information), try to limit the answer to 10 key points and inform the user that they can refer to the search sources for complete information. Prioritize providing the most complete and relevant items in the list. Avoid mentioning content not provided in the search results unless necessary. +- For creative tasks (e.g., writing an essay), ensure that references are cited within the body of the text, such as [citation:3][citation:5], rather than only at the end of the text. You need to interpret and summarize the user's requirements, choose an appropriate format, fully utilize the search results, extract key information, and generate an answer that is insightful, creative, and professional. Extend the length of your response as much as possible, addressing each point in detail and from multiple perspectives, ensuring the content is rich and thorough. +- If the response is lengthy, structure it well and summarize it in paragraphs. If a point-by-point format is needed, try to limit it to 5 points and merge related content. +- For objective Q&A, if the answer is very brief, you may add one or two related sentences to enrich the content. +- Choose an appropriate and visually appealing format for your response based on the user's requirements and the content of the answer, ensuring strong readability. +- Your answer should synthesize information from multiple relevant webpages and avoid repeatedly citing the same webpage. +- Unless the user requests otherwise, your response should be in the same language as the user's question. + +# The user's message is: +{question}`; diff --git a/app/store/access.ts b/app/store/access.ts index b962f95f3..f8b872313 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -142,6 +142,7 @@ const DEFAULT_ACCESS_STATE = { defaultModel: "", visionModels: "", isEnableRAG: false, + isEnableWebSearch: false, // tts config edgeTTSVoiceName: "zh-CN-YunxiNeural", @@ -191,6 +192,12 @@ export const useAccessStore = createPersistStore( return get().isEnableRAG; }, + enableWebSearch() { + this.fetch(); + + return get().isEnableWebSearch; + }, + isValidOpenAI() { return ensure(get(), ["openaiApiKey"]); }, diff --git a/app/store/chat.ts b/app/store/chat.ts index 910769c7d..bb2284563 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -33,8 +33,9 @@ import { ModelConfig, ModelType, useAppConfig } from "./config"; import { useAccessStore } from "./access"; import { collectModelsWithDefaultModel } from "../utils/model"; import { createEmptyMask, Mask } from "./mask"; -import { FileInfo } from "../client/platforms/utils"; +import { FileInfo, WebApi } from "../client/platforms/utils"; import { usePluginStore } from "./plugin"; +import { TavilySearchResponse } from "@tavily/core"; export interface ChatToolMessage { toolName: string; @@ -64,7 +65,7 @@ export type ChatMessage = RequestMessage & { id: string; model?: ModelType; tools?: ChatMessageTool[]; - audio_url?: string; + audioUrl?: string; }; export function createMessage(override: Partial): ChatMessage { @@ -383,9 +384,11 @@ export const useChatStore = createPersistStore( content: string, attachImages?: string[], attachFiles?: FileInfo[], + webSearchReference?: TavilySearchResponse, ) { const session = get().currentSession(); const modelConfig = session.mask.modelConfig; + const accessStore = useAccessStore.getState(); const userContent = fillTemplateWith(content, modelConfig); console.log("[User Input] after template: ", userContent); @@ -413,6 +416,7 @@ export const useChatStore = createPersistStore( role: "user", content: mContent, fileInfos: attachFiles, + webSearchReferences: webSearchReference, }); const botMessage: ChatMessage = createMessage({ @@ -534,6 +538,16 @@ export const useChatStore = createPersistStore( }; agentCall(); } else { + if (session.mask.webSearch && accessStore.enableWebSearch()) { + botMessage.content = Locale.Chat.Searching; + get().updateTargetSession(session, (session) => { + session.messages = session.messages.concat(); + }); + const webApi = new WebApi(); + const webSearchReference = await webApi.search(content); + userMessage.webSearchReferences = webSearchReference; + botMessage.webSearchReferences = webSearchReference; + } // make request api.llm.chat({ messages: sendMessages, diff --git a/app/store/mask.ts b/app/store/mask.ts index a5428bd74..1cf1a7da8 100644 --- a/app/store/mask.ts +++ b/app/store/mask.ts @@ -18,6 +18,7 @@ export type Mask = { lang: Lang; builtin: boolean; usePlugins?: boolean; + webSearch?: boolean; // 上游插件业务参数 plugin?: string[]; enableArtifacts?: boolean; diff --git a/app/utils.ts b/app/utils.ts index 5e8ee9ba2..b613dc95b 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -1,11 +1,15 @@ import { useEffect, useState } from "react"; import { showToast } from "./components/ui-lib"; -import Locale from "./locales"; +import Locale, { getLang } from "./locales"; import { RequestMessage } from "./client/api"; import { DEFAULT_MODELS } from "./constant"; import { ServiceProvider } from "./constant"; // import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http"; import { fetch as tauriStreamFetch } from "./utils/stream"; +import { + WEB_SEARCH_ANSWER_EN_PROMPT, + WEB_SEARCH_ANSWER_ZH_PROMPT, +} from "./prompt"; export function trimTopic(topic: string) { // Fix an issue where double quotes still show in the Indonesian language @@ -227,6 +231,36 @@ export function isMacOS(): boolean { return false; } +export function getWebReferenceMessageTextContent(message: RequestMessage) { + let prompt = getMessageTextContent(message); + if ( + message.webSearchReferences && + message.webSearchReferences.results.length > 0 + ) { + const searchResults = message.webSearchReferences.results + .map((result, index) => { + return `[webpage ${index + 1} begin] +[webpage title]${result.title} +[webpage url]${result.url} +[webpage content begin] +${result.content} +[webpage content end] +[webpage ${index + 1} end] +`; + }) + .join("\n"); + const isZh = getLang() == "cn"; + const promptTemplate = isZh + ? WEB_SEARCH_ANSWER_ZH_PROMPT + : WEB_SEARCH_ANSWER_EN_PROMPT; + prompt = promptTemplate + .replace("{cur_date}", new Date().toLocaleString()) + .replace("{search_results}", searchResults) + .replace("{question}", prompt); + } + return prompt; +} + export function getMessageTextContent(message: RequestMessage) { if (typeof message.content === "string") { return message.content; diff --git a/package.json b/package.json index 235b3c34a..9f520c70b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@qdrant/js-client-rest": "^1.8.2", "@supabase/supabase-js": "^2.44.2", "@svgr/webpack": "^6.5.1", + "@tavily/core": "^0.3.1", "@vercel/analytics": "^0.1.11", "@vercel/speed-insights": "^1.0.2", "axios": "^1.7.5", @@ -109,12 +110,12 @@ "jest-environment-jsdom": "^29.7.0", "lint-staged": "^13.2.2", "prettier": "^3.0.2", + "raw-loader": "^4.0.2", "ts-node": "^10.9.2", "tsx": "^4.16.0", "typescript": "5.2.2", "watch": "^1.0.2", - "webpack": "^5.88.1", - "raw-loader": "^4.0.2" + "webpack": "^5.88.1" }, "resolutions": { "lint-staged/yaml": "^2.2.2", diff --git a/yarn.lock b/yarn.lock index 0a45f4b4c..f0b33ea0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3430,6 +3430,14 @@ "@tauri-apps/cli-win32-ia32-msvc" "1.5.11" "@tauri-apps/cli-win32-x64-msvc" "1.5.11" +"@tavily/core@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@tavily/core/-/core-0.3.1.tgz#bd07c7328be76b061a3dc12e6b978b6ca3a56627" + integrity sha512-7jyvPWG4Zjst0s4v0FMLO1f/dfHqs4FnqvKm86zOGYzXxSfxHu0isbLzlwjJad0csYwF0kifdlECTuNouHfr5A== + dependencies: + axios "^1.7.7" + js-tiktoken "^1.0.14" + "@testing-library/dom@^10.4.0": version "10.4.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.0.tgz#82a9d9462f11d240ecadbf406607c6ceeeff43a8" @@ -4323,7 +4331,7 @@ axe-core@^4.9.1: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.9.1.tgz#fcd0f4496dad09e0c899b44f6c4bb7848da912ae" integrity sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw== -axios@^1.7.5: +axios@^1.7.5, axios@^1.7.7: version "1.7.9" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw== @@ -7876,6 +7884,13 @@ js-tiktoken@^1.0.12: dependencies: base64-js "^1.5.1" +js-tiktoken@^1.0.14: + version "1.0.19" + resolved "https://registry.yarnpkg.com/js-tiktoken/-/js-tiktoken-1.0.19.tgz#0298b584382f1d47d4b45cb93d382f11780eab78" + integrity sha512-XC63YQeEcS47Y53gg950xiZ4IWmkfMe4p2V9OSaBt26q+p47WHn18izuXzSclCI73B7yGqtfRsT6jcZQI0y08g== + dependencies: + base64-js "^1.5.1" + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"