diff --git a/src/renderer/src/aiCore/clients/BaseApiClient.ts b/src/renderer/src/aiCore/clients/BaseApiClient.ts index 4e39e9464d..d8420135c4 100644 --- a/src/renderer/src/aiCore/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/clients/BaseApiClient.ts @@ -1,6 +1,7 @@ import { isFunctionCallingModel, isNotSupportTemperatureAndTopP, + isOpenAIDeepResearchModel, isOpenAIModel, isSupportedFlexServiceTier } from '@renderer/config/models' @@ -205,6 +206,9 @@ export abstract class BaseApiClient< if (isSupportedFlexServiceTier(model)) { return 15 * 1000 * 60 } + if (isOpenAIDeepResearchModel(model)) { + return 60 * 1000 * 60 + } return defaultTimeout } diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 8994b7b2f5..8fc38a9630 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -2,6 +2,7 @@ import { GenericChunk } from '@renderer/aiCore/middleware/schemas' import { CompletionsContext } from '@renderer/aiCore/middleware/types' import { isOpenAIChatCompletionOnlyModel, + isOpenAIDeepResearchModel, isSupportedReasoningEffortOpenAIModel, isVisionModel } from '@renderer/config/models' @@ -373,7 +374,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< reqMessages = [systemMessage, ...userMessage].filter(Boolean) as OpenAI.Responses.EasyInputMessage[] } - if (enableWebSearch) { + if (enableWebSearch || isOpenAIDeepResearchModel(model)) { tools.push({ type: 'web_search_preview' }) diff --git a/src/renderer/src/components/DeepResearchCard.tsx b/src/renderer/src/components/DeepResearchCard.tsx new file mode 100644 index 0000000000..34181d1749 --- /dev/null +++ b/src/renderer/src/components/DeepResearchCard.tsx @@ -0,0 +1,184 @@ +import { getTopicByMessageId } from '@renderer/hooks/useMessageOperations' +import Markdown from '@renderer/pages/home/Markdown/Markdown' +import { useAppDispatch } from '@renderer/store' +import { retryDeepResearchClarificationThunk } from '@renderer/store/thunk/messageThunk' +import { DeepResearchMessageBlock, MessageBlockStatus } from '@renderer/types/newMessage' +import { deepResearchConfirmation } from '@renderer/utils/deepResearchConfirmation' +import { Button, Input } from 'antd' +import { Brain, RotateCcw } from 'lucide-react' +import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import SvgSpinners180Ring from './Icons/SvgSpinners180Ring' + +const { TextArea } = Input + +interface DeepResearchCardProps { + block: DeepResearchMessageBlock +} + +const DeepResearchCard: FC = ({ block }) => { + const { t } = useTranslation() + const dispatch = useAppDispatch() + const [isRetrying, setIsRetrying] = useState(false) + const [userSupplementInfo, setUserSupplementInfo] = useState('') + + const { + metadata: { deepResearchState } + } = block + const isWaitingForContinue = deepResearchState.phase === 'waiting_confirmation' + + const onContinueResearch = () => { + try { + const success = deepResearchConfirmation.triggerResolver(block.id, userSupplementInfo) + if (!success) { + console.error('[continueDeepResearchThunk] No continue resolver found for message', block.id) + return + } + // resolver会在fetchDeepResearch的onResearchStarted中处理后续的研究阶段逻辑 + } catch (error) { + console.error('[continueDeepResearchThunk] Error:', error) + } + } + + const onRetryResearch = async () => { + try { + setIsRetrying(true) + const topic = await getTopicByMessageId(block.messageId) + if (!topic) { + console.error('[onRetryResearch] Topic not found for message', block.messageId) + return + } + // 重试时清空补全信息 + setUserSupplementInfo('') + dispatch(retryDeepResearchClarificationThunk(topic.id, block.messageId)) + } catch (error) { + console.error('[onRetryResearch] Error:', error) + } finally { + setIsRetrying(false) + } + } + + return ( + <> + {block.status === MessageBlockStatus.PENDING ? ( + + ) : ( + + + + + {t('research.clarification.title')} + + {block.content ? ( + + ) : deepResearchState.phase === 'clarification' && block.status === MessageBlockStatus.STREAMING ? ( + + ) : null} + + + {isWaitingForContinue && ( + + {t('research.ready_to_start')} + + + {t('research.supplement_info_label')} + setUserSupplementInfo(e.target.value)} + placeholder={t('research.supplement_info_placeholder')} + rows={3} + maxLength={500} + /> + + + + } + onClick={onRetryResearch} + loading={isRetrying} + disabled={isRetrying}> + {t('research.retry')} + + } onClick={onContinueResearch}> + {t('research.continue_research')} + + + + )} + + )} + + ) +} + +const CardContainer = styled.div` + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-background); + margin: 12px 0; + overflow: hidden; +` + +const ClarificationSection = styled.div` + padding: 16px; + border-bottom: 1px solid var(--color-border-soft); +` + +const SectionTitle = styled.div` + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + color: var(--color-text); + margin-bottom: 12px; +` + +const ActionSection = styled.div` + padding: 16px; + background: var(--color-background-soft); +` + +const ActionTitle = styled.div` + font-size: 14px; + font-weight: 500; + color: var(--color-text); + margin-bottom: 12px; +` + +const ButtonGroup = styled.div` + display: flex; + gap: 8px; +` + +const RetryButton = styled(Button)` + display: flex; + align-items: center; + gap: 4px; +` + +const ContinueButton = styled(Button)` + display: flex; + align-items: center; + gap: 4px; +` + +const SupplementSection = styled.div` + margin-bottom: 12px; +` + +const SupplementLabel = styled.div` + font-size: 14px; + font-weight: 500; + color: var(--color-text); + margin-bottom: 8px; +` + +const StyledTextArea = styled(TextArea)` + width: 100%; +` + +export default DeepResearchCard diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 3bb9d099fa..f392522037 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2428,6 +2428,16 @@ export function isOpenAIWebSearchModel(model: Model): boolean { ) } +export function isOpenAIDeepResearchModel(model?: Model): boolean { + if (!model) { + return false + } + if (!isOpenAIModel(model)) { + return false + } + return model.id.includes('deep-research') +} + export function isSupportedThinkingTokenModel(model?: Model): boolean { if (!model) { return false diff --git a/src/renderer/src/config/prompts.ts b/src/renderer/src/config/prompts.ts index ead2dc1333..c6766ed3c3 100644 --- a/src/renderer/src/config/prompts.ts +++ b/src/renderer/src/config/prompts.ts @@ -455,3 +455,82 @@ Example: [nytimes.com](https://nytimes.com/some-page). If have multiple citations, please directly list them like this: [www.nytimes.com](https://nytimes.com/some-page)[www.bbc.com](https://bbc.com/some-page) ` + +export const DEEP_RESEARCH_CLARIFICATION_PROMPT = ` +You are talking to a user who is asking for a research task to be conducted. Your job is to gather more information from the user to successfully complete the task. + +GUIDELINES: +- Be concise while gathering all necessary information** +- Make sure to gather all the information needed to carry out the research task in a concise, well-structured manner. +- Use bullet points or numbered lists if appropriate for clarity. +- Don't ask for unnecessary information, or information that the user has already provided. +- Use user's language to ask questions. + +IMPORTANT: Do NOT conduct any research yourself, just gather information that will be given to a researcher to conduct the research task. +` + +export const DEEP_RESEARCH_PROMPT_REWRITE_PROMPT = ` +You will be given a research task by a user. Your job is to produce a set of +instructions for a researcher that will complete the task. Do NOT complete the +task yourself, just provide instructions on how to complete it. + +GUIDELINES: +1. **Maximize Specificity and Detail** +- Include all known user preferences and explicitly list key attributes or + dimensions to consider. +- It is of utmost importance that all details from the user are included in + the instructions. + +2. **Fill in Unstated But Necessary Dimensions as Open-Ended** +- If certain attributes are essential for a meaningful output but the user + has not provided them, explicitly state that they are open-ended or default + to no specific constraint. + +3. **Avoid Unwarranted Assumptions** +- If the user has not provided a particular detail, do not invent one. +- Instead, state the lack of specification and guide the researcher to treat + it as flexible or accept all possible options. + +4. **Use the First Person** +- Phrase the request from the perspective of the user. + +5. **Tables** +- If you determine that including a table will help illustrate, organize, or + enhance the information in the research output, you must explicitly request + that the researcher provide them. + +Examples: +- Product Comparison (Consumer): When comparing different smartphone models, + request a table listing each model's features, price, and consumer ratings + side-by-side. +- Project Tracking (Work): When outlining project deliverables, create a table + showing tasks, deadlines, responsible team members, and status updates. +- Budget Planning (Consumer): When creating a personal or household budget, + request a table detailing income sources, monthly expenses, and savings goals. +- Competitor Analysis (Work): When evaluating competitor products, request a + table with key metrics, such as market share, pricing, and main differentiators. + +6. **Headers and Formatting** +- You should include the expected output format in the prompt. +- If the user is asking for content that would be best returned in a + structured format (e.g. a report, plan, etc.), ask the researcher to format + as a report with the appropriate headers and formatting that ensures clarity + and structure. + +7. **Language** +- If the user input is in a language other than English, tell the researcher + to respond in this language, unless the user query explicitly asks for the + response in a different language. + +8. **Sources** +- If specific sources should be prioritized, specify them in the prompt. +- For product and travel research, prefer linking directly to official or + primary websites (e.g., official brand sites, manufacturer pages, or + reputable e-commerce platforms like Amazon for user reviews) rather than + aggregator sites or SEO-heavy blogs. +- For academic or scientific queries, prefer linking directly to the original + paper or official journal publication rather than survey papers or secondary + summaries. +- If the query is in a specific language, prioritize sources published in that + language. +` diff --git a/src/renderer/src/hooks/useMessageOperations.ts b/src/renderer/src/hooks/useMessageOperations.ts index 559b4ad879..e15daec6de 100644 --- a/src/renderer/src/hooks/useMessageOperations.ts +++ b/src/renderer/src/hooks/useMessageOperations.ts @@ -16,8 +16,8 @@ import { removeBlocksThunk, resendMessageThunk, resendUserMessageWithEditThunk, - updateMessageAndBlocksThunk, - updateTranslationBlockThunk + updateBlockThunk, + updateMessageAndBlocksThunk } from '@renderer/store/thunk/messageThunk' import type { Assistant, Model, Topic } from '@renderer/types' import type { Message, MessageBlock } from '@renderer/types/newMessage' @@ -26,6 +26,8 @@ import { abortCompletion } from '@renderer/utils/abortController' import { throttle } from 'lodash' import { useCallback } from 'react' +import { TopicManager } from './useTopic' + const selectMessagesState = (state: RootState) => state.messages export const selectNewTopicLoading = createSelector( @@ -232,7 +234,7 @@ export function useMessageOperations(topic: Topic) { } } dispatch(updateOneBlock({ id: blockId, changes })) - await dispatch(updateTranslationBlockThunk(blockId, '', false)) + await dispatch(updateBlockThunk(blockId, '', false)) } else { blockId = await dispatch( initiateTranslationThunk(messageId, topic.id, targetLanguage, sourceBlockId, sourceLanguage) @@ -246,7 +248,7 @@ export function useMessageOperations(topic: Topic) { return throttle( (accumulatedText: string, isComplete: boolean = false) => { - dispatch(updateTranslationBlockThunk(blockId!, accumulatedText, isComplete)) + dispatch(updateBlockThunk(blockId!, accumulatedText, isComplete)) }, 200, { leading: true, trailing: true } @@ -452,3 +454,18 @@ export const useTopicMessages = (topicId: string) => { export const useTopicLoading = (topic: Topic) => { return useAppSelector((state) => selectNewTopicLoading(state, topic.id)) } + +export const getTopicByMessageId = async (messageId: string) => { + const state = store.getState() + const message = state.messages.entities[messageId] + if (!message) { + return null + } + const topicId = message.topicId + console.log('[getTopicByMessageId] topicId', topicId) + const topic = await TopicManager.getTopic(topicId) + if (!topic) { + return null + } + return topic +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b8b60ece63..691c81953c 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2053,6 +2053,16 @@ "install": "Install", "noReleaseNotes": "No release notes" }, + "research": { + "clarification": { + "title": "Research Clarification" + }, + "ready_to_start": "Ready to start deep research", + "retry": "Retry Clarification", + "continue_research": "Start Research", + "supplement_info_label": "Additional Information (Optional)", + "supplement_info_placeholder": "You can provide additional information here to help us better understand your requirements..." + }, "selection": { "name": "Selection Assistant", "action": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 1dbf64f933..fbd1d32ba0 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -2220,6 +2220,16 @@ "user_tips": "アプリケーションの実行ファイル名を1行ずつ入力してください。大文字小文字は区別しません。例: chrome.exe, weixin.exe, Cherry Studio.exe, など。" } } + }, + "research": { + "clarification": { + "title": "研究の明確化" + }, + "ready_to_start": "深い研究を開始する準備ができました", + "retry": "再明確化", + "continue_research": "研究を続ける", + "supplement_info_label": "補足情報 (任意)", + "supplement_info_placeholder": "ここに補足情報を提供して、より良く理解してください..." } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 4be09aa0a8..8fae508560 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -2220,6 +2220,16 @@ "user_tips": "Введите имя исполняемого файла приложения, один на строку, не учитывая регистр, можно использовать подстановку *" } } + }, + "research": { + "clarification": { + "title": "Уточнение исследования" + }, + "ready_to_start": "Готов к началу глубокого исследования", + "retry": "Повторное уточнение", + "continue_research": "Продолжить исследование", + "supplement_info_label": "Дополнительная информация (необязательно)", + "supplement_info_placeholder": "Вы можете предоставить дополнительную информацию здесь, чтобы помочь нам лучше понять ваши требования..." } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 475f766993..05d2ce84d9 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2053,6 +2053,16 @@ "install": "立即安装", "noReleaseNotes": "暂无更新日志" }, + "research": { + "clarification": { + "title": "研究澄清" + }, + "ready_to_start": "准备开始深度研究", + "retry": "重新澄清", + "continue_research": "开始研究", + "supplement_info_label": "补充信息(可选)", + "supplement_info_placeholder": "您可以在这里补充更多信息,帮助我们更好地理解您的需求..." + }, "selection": { "name": "划词助手", "action": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index edcaee1e85..ef009bf044 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2220,6 +2220,16 @@ "user_tips": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe 等" } } + }, + "research": { + "clarification": { + "title": "研究澄清" + }, + "ready_to_start": "準備開始深度研究", + "retry": "重新澄清", + "continue_research": "繼續研究", + "supplement_info_label": "補充資訊 (可選)", + "supplement_info_placeholder": "您可以在此處提供補充資訊,幫助我們更好地理解您的需求..." } } } diff --git a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx index 21db131cef..132ca7c4ff 100644 --- a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx @@ -8,6 +8,7 @@ import { import { useQuickPanel } from '@renderer/components/QuickPanel' import { isDoubaoThinkingAutoModel, + isOpenAIDeepResearchModel, isSupportedReasoningEffortGrokModel, isSupportedThinkingTokenDoubaoModel, isSupportedThinkingTokenGeminiModel, @@ -38,7 +39,8 @@ const MODEL_SUPPORTED_OPTIONS: Record = { grok: ['off', 'low', 'high'], gemini: ['off', 'low', 'medium', 'high', 'auto'], qwen: ['off', 'low', 'medium', 'high'], - doubao: ['off', 'auto', 'high'] + doubao: ['off', 'auto', 'high'], + openai_deep_research: ['off', 'medium'] } // 选项转换映射表:当选项不支持时使用的替代选项 @@ -46,7 +48,7 @@ const OPTION_FALLBACK: Record = { off: 'off', low: 'high', medium: 'high', // medium -> high (for Grok models) - high: 'high', + high: 'medium', auto: 'high' // auto -> high (for non-Gemini models) } @@ -59,6 +61,7 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re const isGeminiModel = isSupportedThinkingTokenGeminiModel(model) const isQwenModel = isSupportedThinkingTokenQwenModel(model) const isDoubaoModel = isSupportedThinkingTokenDoubaoModel(model) + const isDeepResearchModel = isOpenAIDeepResearchModel(model) const currentReasoningEffort = useMemo(() => { return assistant.settings?.reasoning_effort || 'off' @@ -70,8 +73,9 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re if (isGrokModel) return 'grok' if (isQwenModel) return 'qwen' if (isDoubaoModel) return 'doubao' + if (isDeepResearchModel) return 'openai_deep_research' return 'default' - }, [isGeminiModel, isGrokModel, isQwenModel, isDoubaoModel]) + }, [isGeminiModel, isGrokModel, isQwenModel, isDoubaoModel, isDeepResearchModel]) // 获取当前模型支持的选项 const supportedOptions = useMemo(() => { diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 454550c5c8..27eadaef3c 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -6,7 +6,12 @@ import ImageViewer from '@renderer/components/ImageViewer' import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer' import { useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' -import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage' +import type { + DeepResearchMessageBlock, + MainTextMessageBlock, + ThinkingMessageBlock, + TranslationMessageBlock +} from '@renderer/types/newMessage' import { parseJSON } from '@renderer/utils' import { removeSvgEmptyLines } from '@renderer/utils/formats' import { findCitationInChildren, getCodeBlockId, processLatexBrackets } from '@renderer/utils/markdown' @@ -33,7 +38,7 @@ const DISALLOWED_ELEMENTS = ['iframe'] interface Props { // message: Message & { content: string } - block: MainTextMessageBlock | TranslationMessageBlock | ThinkingMessageBlock + block: MainTextMessageBlock | TranslationMessageBlock | ThinkingMessageBlock | DeepResearchMessageBlock } const Markdown: FC = ({ block }) => { diff --git a/src/renderer/src/pages/home/Messages/Blocks/DeepResearchBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/DeepResearchBlock.tsx new file mode 100644 index 0000000000..f2f99d604e --- /dev/null +++ b/src/renderer/src/pages/home/Messages/Blocks/DeepResearchBlock.tsx @@ -0,0 +1,13 @@ +import DeepResearchCard from '@renderer/components/DeepResearchCard' +import type { DeepResearchMessageBlock } from '@renderer/types/newMessage' +import React from 'react' + +interface Props { + block: DeepResearchMessageBlock +} + +const DeepResearchBlock: React.FC = ({ block }) => { + return +} + +export default React.memo(DeepResearchBlock) diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx index 9f4d6e838a..c94a6bbe26 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx @@ -8,6 +8,7 @@ import { useSelector } from 'react-redux' import styled from 'styled-components' import CitationBlock from './CitationBlock' +import DeepResearchBlock from './DeepResearchBlock' import ErrorBlock from './ErrorBlock' import FileBlock from './FileBlock' import ImageBlock from './ImageBlock' @@ -143,6 +144,9 @@ const MessageBlockRenderer: React.FC = ({ blocks, message }) => { case MessageBlockType.TRANSLATION: blockComponent = break + case MessageBlockType.DEEP_RESEARCH: + blockComponent = + break default: console.warn('Unsupported block type in MessageBlockRenderer:', (block as any).type, block) break diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 4704a8bfd3..459b7493e9 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -3,6 +3,7 @@ import Logger from '@renderer/config/logger' import { isEmbeddingModel, isGenerateImageModel, + isOpenAIDeepResearchModel, isOpenRouterBuiltInWebSearchModel, isReasoningModel, isSupportedDisableGenerationModel, @@ -332,7 +333,13 @@ export async function fetchChatCompletion({ // try { // NOTE: The search results are NOT added to the messages sent to the AI here. // They will be retrieved and used by the messageThunk later to create CitationBlocks. - const { mcpTools } = await fetchExternalTool(lastUserMessage, assistant, onChunkReceived, lastAnswer) + + const mcpTools: MCPTool[] = [] + if (!isOpenAIDeepResearchModel(assistant.model || getDefaultModel())) { + const { mcpTools: tools } = await fetchExternalTool(lastUserMessage, assistant, onChunkReceived, lastAnswer) + mcpTools.push(...(tools || [])) + } + const model = assistant.model || getDefaultModel() const { maxTokens, contextCount } = getAssistantSettings(assistant) diff --git a/src/renderer/src/services/DeepResearchService.ts b/src/renderer/src/services/DeepResearchService.ts new file mode 100644 index 0000000000..1f25569c20 --- /dev/null +++ b/src/renderer/src/services/DeepResearchService.ts @@ -0,0 +1,151 @@ +import AiProvider from '@renderer/aiCore' +import { CompletionsParams } from '@renderer/aiCore/middleware/schemas' +import { + isOpenAIDeepResearchModel, + isReasoningModel, + isSupportedReasoningEffortModel, + isSupportedThinkingTokenModel +} from '@renderer/config/models' +import { DEEP_RESEARCH_CLARIFICATION_PROMPT, DEEP_RESEARCH_PROMPT_REWRITE_PROMPT } from '@renderer/config/prompts' +import { Assistant, Message } from '@renderer/types' +import { Chunk } from '@renderer/types/chunk' +import { findLast } from 'lodash' + +import { fetchChatCompletion } from './ApiService' +import { getAssistantProvider, getDefaultAssistant, getDefaultModel, getTopNamingModel } from './AssistantService' + +interface DeepResearchCallbacks { + onResearchStarted: () => Promise // 返回用户补全信息 + onResponse: (text: string, isComplete: boolean) => void + onChunkReceived: (chunk: Chunk) => void +} + +// 澄清阶段:生成澄清问题 +export async function fetchDeepResearchClarification({ + messages, + assistant, + onResponse +}: { + messages: Message[] + assistant: Assistant + onResponse: (text: string, isComplete: boolean) => void +}) { + const clarificationAssistant = getDefaultAssistant() + const model = getTopNamingModel() || getDefaultModel() + clarificationAssistant.model = model + clarificationAssistant.prompt = DEEP_RESEARCH_CLARIFICATION_PROMPT + + const lastUserMessage = findLast(messages, (m) => m.role === 'user') + if (!lastUserMessage) { + throw new Error('No user message found for clarification') + } + + const enableReasoning = + ((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) && + assistant.settings?.reasoning_effort !== undefined) || + (isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model))) + + const params: CompletionsParams = { + callType: 'chat', + messages: [lastUserMessage], + onResponse: onResponse, + assistant: clarificationAssistant, + streamOutput: assistant.settings?.streamOutput || false, + enableReasoning + } + + const provider = getAssistantProvider(clarificationAssistant) + const AI = new AiProvider(provider) + + const result = await AI.completions(params, { + streamOutput: assistant.settings?.streamOutput || false + }) + return result.getText() +} + +// 提示词重写阶段 +export async function fetchDeepResearchPromptRewrite( + clarificationAnswers: string, + userSupplementInfo?: string +): Promise { + const rewriteAssistant = getDefaultAssistant() + rewriteAssistant.model = getTopNamingModel() || getDefaultModel() + + rewriteAssistant.prompt = DEEP_RESEARCH_PROMPT_REWRITE_PROMPT + + // 构建包含澄清答案和用户补全信息的完整内容 + let contentForRewrite = clarificationAnswers + if (userSupplementInfo && userSupplementInfo.trim()) { + contentForRewrite += `\n\n用户补充信息:\n${userSupplementInfo.trim()}` + } + + const params: CompletionsParams = { + callType: 'summary', + messages: contentForRewrite, + assistant: rewriteAssistant, + streamOutput: false, + enableReasoning: false + } + + const provider = getAssistantProvider(rewriteAssistant) + const AI = new AiProvider(provider) + + try { + const result = await AI.completions(params) + const rewrittenPrompt = result.getText() + + return rewrittenPrompt + } catch (error: any) { + console.error('Prompt rewrite phase failed:', error) + return contentForRewrite + } +} + +// 主要的Deep Research函数 +export async function fetchDeepResearch({ + messages, + assistant, + callbacks +}: { + messages: Message[] + assistant: Assistant + callbacks: DeepResearchCallbacks +}) { + const model = assistant.model || getDefaultModel() + if (!isOpenAIDeepResearchModel(model)) { + throw new Error('Model is not supported for deep research') + } + + const lastUserMessage = findLast(messages, (m) => m.role === 'user') + if (!lastUserMessage) { + throw new Error('No user message found for deep research') + } + + try { + // 阶段1:澄清用户意图 + const clarificationAnswers = await fetchDeepResearchClarification({ + messages, + assistant, + onResponse: callbacks.onResponse + }) + + // 等待用户确认并获取补全信息 + const userSupplementInfo = await callbacks.onResearchStarted() + + // 阶段2:重写提示词 + const rewrittenPrompt = await fetchDeepResearchPromptRewrite(clarificationAnswers, userSupplementInfo) + + // 使用增强后的提示词调用Deep Research模型 + await fetchChatCompletion({ + messages: [lastUserMessage], + assistant: { + ...assistant, + prompt: rewrittenPrompt + }, + onChunkReceived: callbacks.onChunkReceived + }) + } catch (error: any) { + console.error('Deep research failed:', error) + throw error + } +} diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index f329af0c7d..a6f0dd8798 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -1,6 +1,8 @@ import db from '@renderer/databases' import { autoRenameTopic } from '@renderer/hooks/useTopic' +import { isOpenAIDeepResearchModel } from '@renderer/config/models' import { fetchChatCompletion } from '@renderer/services/ApiService' +import { fetchDeepResearch } from '@renderer/services/DeepResearchService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import FileManager from '@renderer/services/FileManager' import { NotificationService } from '@renderer/services/NotificationService' @@ -10,6 +12,7 @@ import store from '@renderer/store' import type { Assistant, ExternalToolResult, FileType, MCPToolResponse, Model, Topic } from '@renderer/types' import type { CitationMessageBlock, + DeepResearchMessageBlock, FileMessageBlock, ImageMessageBlock, Message, @@ -20,11 +23,14 @@ import type { import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' import { Response } from '@renderer/types/newMessage' import { uuid } from '@renderer/utils' +import { abortCompletion } from '@renderer/utils/abortController' +import { deepResearchConfirmation } from '@renderer/utils/deepResearchConfirmation' import { formatErrorMessage, isAbortError } from '@renderer/utils/error' import { createAssistantMessage, createBaseMessageBlock, createCitationBlock, + createDeepResearchBlock, createErrorBlock, createImageBlock, createMainTextBlock, @@ -34,7 +40,7 @@ import { resetAssistantMessage } from '@renderer/utils/messageUtils/create' import { getMainTextContent } from '@renderer/utils/messageUtils/find' -import { getTopicQueue } from '@renderer/utils/queue' +import { clearTopicQueue, getTopicQueue } from '@renderer/utils/queue' import { isOnHomePage } from '@renderer/utils/window' import { t } from 'i18next' import { isEmpty, throttle } from 'lodash' @@ -43,6 +49,7 @@ import { LRUCache } from 'lru-cache' import type { AppDispatch, RootState } from '../index' import { removeManyBlocks, updateOneBlock, upsertManyBlocks, upsertOneBlock } from '../messageBlock' import { newMessagesActions, selectMessagesForTopic } from '../newMessage' +import Logger from '@renderer/config/logger' // const handleChangeLoadingOfTopic = async (topicId: string) => { // await waitForTopicQueue(topicId) @@ -522,7 +529,7 @@ const fetchAndProcessAssistantResponseImpl = async ( onToolCallInProgress: (toolResponse: MCPToolResponse) => { if (initialPlaceholderBlockId) { lastBlockType = MessageBlockType.TOOL - const changes = { + const changes: Partial = { type: MessageBlockType.TOOL, status: MessageBlockStatus.PROCESSING, metadata: { rawMcpToolResponse: toolResponse } @@ -597,7 +604,7 @@ const fetchAndProcessAssistantResponseImpl = async ( if (initialPlaceholderBlockId) { lastBlockType = MessageBlockType.CITATION citationBlockId = initialPlaceholderBlockId - const changes = { + const changes: Partial = { type: MessageBlockType.CITATION, status: MessageBlockStatus.PROCESSING } @@ -642,7 +649,7 @@ const fetchAndProcessAssistantResponseImpl = async ( onImageCreated: async () => { if (initialPlaceholderBlockId) { lastBlockType = MessageBlockType.IMAGE - const initialChanges: Partial = { + const initialChanges: Partial = { type: MessageBlockType.IMAGE, status: MessageBlockStatus.STREAMING } @@ -829,11 +836,49 @@ const fetchAndProcessAssistantResponseImpl = async ( const streamProcessorCallbacks = createStreamProcessor(callbacks) const startTime = Date.now() - await fetchChatCompletion({ - messages: messagesForContext, - assistant: assistant, - onChunkReceived: streamProcessorCallbacks - }) + + if (isOpenAIDeepResearchModel(assistant.model)) { + const deepResearchBlock = await handleDeepResearchFlow(dispatch, getState, topicId, assistantMessage) + const clarificationUpdater = getClarificationUpdater(assistantMessage.id, dispatch) + if (!clarificationUpdater) { + return + } + await fetchDeepResearch({ + messages: messagesForContext, + assistant, + callbacks: { + onResearchStarted: async (): Promise => { + // 等待用户确认并获取补全信息 + return new Promise((resolve) => { + deepResearchConfirmation.registerResolver(deepResearchBlock.id, (userSupplementInfo?: string) => { + dispatch( + updateOneBlock({ + id: deepResearchBlock.id, + changes: { + metadata: { + deepResearchState: { + phase: 'research' + } + } + } + }) + ) + resolve(userSupplementInfo) + }) + }) + }, + onResponse: clarificationUpdater, + onChunkReceived: streamProcessorCallbacks + } + }) + } else { + // 正常聊天流程 + await fetchChatCompletion({ + messages: messagesForContext, + assistant: assistant, + onChunkReceived: streamProcessorCallbacks + }) + } } catch (error: any) { console.error('Error fetching chat completion:', error) if (assistantMessage) { @@ -843,6 +888,101 @@ const fetchAndProcessAssistantResponseImpl = async ( } } +const getClarificationUpdater = (messageId: string, dispatch: AppDispatch) => { + const state = store.getState() + const message = state.messages.entities[messageId] + if (!message) { + return null + } + let deepResearchBlockId: string | undefined + if (message.blocks && message.blocks.length > 0) { + for (const blockId of message.blocks) { + const block = state.messageBlocks.entities[blockId] + if (block && block.type === MessageBlockType.DEEP_RESEARCH) { + deepResearchBlockId = blockId + break + } + } + } + if (deepResearchBlockId) { + const blockId = deepResearchBlockId + const changes: Partial = { + content: '', + status: MessageBlockStatus.STREAMING, + metadata: { + deepResearchState: { + phase: 'clarification' + } + } + } + dispatch(updateOneBlock({ id: blockId, changes })) + return throttle( + (accumulatedText: string, isComplete: boolean = false) => { + dispatch(updateBlockThunk(blockId, accumulatedText, isComplete)) + + // 澄清阶段完成,更新状态为等待用户确认 + if (isComplete) { + dispatch( + updateOneBlock({ + id: blockId, + changes: { + metadata: { + deepResearchState: { + phase: 'waiting_confirmation' + } + } + } + }) + ) + } + }, + 200, + { leading: true, trailing: true } + ) + } + return null +} + +/** + * 处理Deep Research流程初始化 + */ +const handleDeepResearchFlow = async ( + dispatch: AppDispatch, + getState: () => RootState, + topicId: string, + assistantMessage: Message +) => { + // 创建Deep Research状态块并标记消息类型 + const deepResearchBlock = createDeepResearchBlock( + assistantMessage.id, + '', + { + phase: 'clarification' + }, + { + status: MessageBlockStatus.PENDING + } + ) + + dispatch(upsertOneBlock(deepResearchBlock)) + dispatch( + newMessagesActions.updateMessage({ + topicId, + messageId: assistantMessage.id, + updates: { + blocks: [...(assistantMessage.blocks || []), deepResearchBlock.id], + type: 'deep_research' + } + }) + ) + const finalMessagesToSave = selectMessagesForTopic(getState(), topicId) + await db.transaction('rw', db.topics, db.message_blocks, async () => { + await db.message_blocks.put(deepResearchBlock) + await db.topics.update(topicId, { messages: finalMessagesToSave }) + }) + return deepResearchBlock +} + /** * 发送消息并处理助手回复 * @param userMessage 已创建的用户消息 @@ -889,6 +1029,83 @@ export const sendMessage = // } } +/** + * 重试Deep Research澄清阶段 + */ +export const retryDeepResearchClarificationThunk = + (topicId: string, messageId: string) => async (dispatch: AppDispatch, getState: () => RootState) => { + try { + const state = getState() + const message = state.messages.entities[messageId] + + if (!message) { + Logger.error(`[retryDeepResearchClarificationThunk] Message ${messageId} not found in state`) + return + } + + // 找到并清除澄清相关的块,保留状态块但重置其状态 + const blocksToRemove: string[] = [] + const blocksToUpdate: MessageBlock[] = [] + + message.blocks?.forEach((blockId) => { + const block = state.messageBlocks.entities[blockId] + if (block && block.type === MessageBlockType.DEEP_RESEARCH) { + // 清理现有的resolver + deepResearchConfirmation.clearResolver(blockId) + + if (block.type === MessageBlockType.DEEP_RESEARCH) { + blocksToRemove.push(blockId) + } + } + }) + + if (blocksToRemove.length > 0) { + cleanupMultipleBlocks(dispatch, blocksToRemove) + + // 更新消息的blocks数组 + const updatedBlocks = (message.blocks || []).filter((id) => !blocksToRemove.includes(id)) + dispatch( + newMessagesActions.updateMessage({ + topicId, + messageId, + updates: { blocks: updatedBlocks } + }) + ) + await db.message_blocks.bulkDelete(blocksToRemove) + } + + if (blocksToUpdate.length > 0) { + dispatch(upsertManyBlocks(blocksToUpdate)) + } + + // 1. 先中止当前正在执行的任务(如果有的话) + if (message.askId) { + try { + abortCompletion(message.askId) + } catch (error) { + Logger.warn(`[retryDeepResearchClarificationThunk] Failed to abort current task:`, error) + } + } + + // 2. 清空队列中的待处理任务并重新添加新任务 + clearTopicQueue(topicId) + + const queue = getTopicQueue(topicId) + + queue.add(async () => { + const assistant = state.assistants.assistants.find((a) => a.id === message.assistantId) + if (assistant) { + await fetchAndProcessAssistantResponseImpl(dispatch, getState, topicId, assistant, message) + } else { + Logger.error(`[retryDeepResearchClarificationThunk] Assistant ${message.assistantId} not found`) + } + }) + } catch (error) { + Logger.error(`[retryDeepResearchClarificationThunk] Unexpected error during retry:`, error) + throw error + } + } + /** * Loads messages and their blocks for a specific topic from the database * and updates the Redux store. @@ -1307,7 +1524,7 @@ export const initiateTranslationThunk = } // --- Thunk to update the translation block with new content --- -export const updateTranslationBlockThunk = +export const updateBlockThunk = (blockId: string, accumulatedText: string, isComplete: boolean = false) => async (dispatch: AppDispatch) => { // Logger.log(`[updateTranslationBlockThunk] 更新翻译块 ${blockId}, isComplete: ${isComplete}`) diff --git a/src/renderer/src/types/newMessage.ts b/src/renderer/src/types/newMessage.ts index 8e1a5c26dd..c900b6c377 100644 --- a/src/renderer/src/types/newMessage.ts +++ b/src/renderer/src/types/newMessage.ts @@ -26,7 +26,8 @@ export enum MessageBlockType { TOOL = 'tool', // Added unified tool block type FILE = 'file', // 文件内容 ERROR = 'error', // 错误信息 - CITATION = 'citation' // 引用类型 (Now includes web search, grounding, etc.) + CITATION = 'citation', // 引用类型 (Now includes web search, grounding, etc.) + DEEP_RESEARCH = 'deep_research' // Deep Research } // 块状态定义 @@ -131,6 +132,15 @@ export interface ErrorMessageBlock extends BaseMessageBlock { type: MessageBlockType.ERROR } +// Deep Research状态块 +export interface DeepResearchMessageBlock extends BaseMessageBlock { + type: MessageBlockType.DEEP_RESEARCH + content: string + metadata: BaseMessageBlock['metadata'] & { + deepResearchState: DeepResearchMetadata + } +} + // MessageBlock 联合类型 export type MessageBlock = | PlaceholderMessageBlock @@ -143,6 +153,7 @@ export type MessageBlock = | FileMessageBlock | ErrorMessageBlock | CitationMessageBlock + | DeepResearchMessageBlock export enum UserMessageStatus { SUCCESS = 'success' @@ -156,6 +167,15 @@ export enum AssistantMessageStatus { PAUSED = 'paused', ERROR = 'error' } + +// Deep Research相关类型定义 +export interface DeepResearchMetadata { + phase: 'clarification' | 'waiting_confirmation' | 'research' | 'completed' + clarificationBlockId?: string +} + +// 扩展消息类型 +export type MessageType = 'clear' | 'deep_research' // Message 核心类型 - 包含元数据和块集合 export type Message = { id: string @@ -169,7 +189,7 @@ export type Message = { // 消息元数据 modelId?: string model?: Model - type?: 'clear' + type?: MessageType useful?: boolean askId?: string // 关联的问题消息ID mentions?: Model[] diff --git a/src/renderer/src/utils/deepResearchConfirmation.ts b/src/renderer/src/utils/deepResearchConfirmation.ts new file mode 100644 index 0000000000..ab48752f99 --- /dev/null +++ b/src/renderer/src/utils/deepResearchConfirmation.ts @@ -0,0 +1,68 @@ +// Deep Research确认机制管理 +interface DeepResearchResolver { + blockId: string + resolve: (userSupplementInfo?: string) => void + createdAt: number +} + +class DeepResearchConfirmationManager { + private resolvers = new Map() + + // 注册一个resolver + registerResolver(blockId: string, resolve: (userSupplementInfo?: string) => void): void { + this.resolvers.set(blockId, { + blockId, + resolve, + createdAt: Date.now() + }) + } + + // 触发resolver并传递用户补全信息 + triggerResolver(blockId: string, userSupplementInfo?: string): boolean { + const resolver = this.resolvers.get(blockId) + if (resolver) { + resolver.resolve(userSupplementInfo) + this.resolvers.delete(blockId) + return true + } + return false + } + + // 清理resolver + clearResolver(blockId: string): void { + this.resolvers.delete(blockId) + } + + // 检查是否存在resolver + hasResolver(blockId: string): boolean { + return this.resolvers.has(blockId) + } + + // 清理过期的resolvers (超过10分钟) + cleanupExpiredResolvers(): void { + const now = Date.now() + const expireTime = 10 * 60 * 1000 // 10分钟 + + for (const [blockId, resolver] of this.resolvers.entries()) { + if (now - resolver.createdAt > expireTime) { + this.resolvers.delete(blockId) + } + } + } + + // 获取所有pending的message IDs + getPendingblockIds(): string[] { + return Array.from(this.resolvers.keys()) + } +} + +// 导出单例实例 +export const deepResearchConfirmation = new DeepResearchConfirmationManager() + +// 定期清理过期的resolvers +setInterval( + () => { + deepResearchConfirmation.cleanupExpiredResolvers() + }, + 5 * 60 * 1000 +) // 每5分钟清理一次 diff --git a/src/renderer/src/utils/messageUtils/create.ts b/src/renderer/src/utils/messageUtils/create.ts index 1a7450aad7..c41b253efd 100644 --- a/src/renderer/src/utils/messageUtils/create.ts +++ b/src/renderer/src/utils/messageUtils/create.ts @@ -5,6 +5,8 @@ import type { BaseMessageBlock, CitationMessageBlock, CodeMessageBlock, + DeepResearchMessageBlock, + DeepResearchMetadata, ErrorMessageBlock, FileMessageBlock, ImageMessageBlock, @@ -273,6 +275,30 @@ export function createCitationBlock( } } +/** + * Creates a Deep Research Message Block. + * @param messageId - The ID of the parent message. + * @param deepResearchState - The deep research state metadata. + * @param overrides - Optional properties to override the defaults. + * @returns A DeepResearchStateMessageBlock object. + */ +export function createDeepResearchBlock( + messageId: string, + content: string, + deepResearchState: DeepResearchMetadata, + overrides: Partial> = {} +): DeepResearchMessageBlock { + const baseBlock = createBaseMessageBlock(messageId, MessageBlockType.DEEP_RESEARCH, overrides) + return { + ...baseBlock, + content, + metadata: { + ...baseBlock.metadata, + deepResearchState + } + } +} + /** * Creates a new Message object * @param role - The role of the message sender ('user' or 'assistant').