mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 12:44:21 +08:00
1.2.7
This commit is contained in:
parent
4dde843ef4
commit
7faf8ec27b
909
OpenAIProvider.ts
Normal file
909
OpenAIProvider.ts
Normal file
@ -0,0 +1,909 @@
|
||||
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
|
||||
import {
|
||||
getOpenAIWebSearchParams,
|
||||
isGrokReasoningModel,
|
||||
isHunyuanSearchModel,
|
||||
isOpenAIoSeries,
|
||||
isOpenAIWebSearch,
|
||||
isReasoningModel,
|
||||
isSupportedModel,
|
||||
isVisionModel,
|
||||
isZhipuModel
|
||||
} from '@renderer/config/models'
|
||||
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES } from '@renderer/services/EventService'
|
||||
import {
|
||||
filterContextMessages,
|
||||
filterEmptyMessages,
|
||||
filterUserRoleStartMessages
|
||||
} from '@renderer/services/MessagesService'
|
||||
import store from '@renderer/store'
|
||||
import {
|
||||
Assistant,
|
||||
FileTypes,
|
||||
GenerateImageParams,
|
||||
MCPToolResponse,
|
||||
Model,
|
||||
Provider,
|
||||
Suggestion
|
||||
} from '@renderer/types'
|
||||
import { Message } from '@renderer/types/newMessageTypes'
|
||||
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
|
||||
import { addImageFileToContents } from '@renderer/utils/formats'
|
||||
import { findFileBlocks, findImageBlocks, getMessageContent } from '@renderer/utils/messageUtils/find'
|
||||
import { mcpToolCallResponseToOpenAIMessage, parseAndCallTools } from '@renderer/utils/mcp-tools'
|
||||
import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
import { takeRight } from 'lodash'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import {
|
||||
ChatCompletionContentPart,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionMessageParam
|
||||
} from 'openai/resources'
|
||||
|
||||
import { CompletionsParams } from '.'
|
||||
import BaseProvider from './BaseProvider'
|
||||
|
||||
type ReasoningEffort = 'high' | 'medium' | 'low'
|
||||
|
||||
export default class OpenAIProvider extends BaseProvider {
|
||||
private sdk: OpenAI
|
||||
|
||||
constructor(provider: Provider) {
|
||||
super(provider)
|
||||
|
||||
if (provider.id === 'azure-openai' || provider.type === 'azure-openai') {
|
||||
this.sdk = new AzureOpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
apiVersion: provider.apiVersion,
|
||||
endpoint: provider.apiHost
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.sdk = new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provider does not support files
|
||||
* @returns True if the provider does not support files, false otherwise
|
||||
*/
|
||||
private get isNotSupportFiles() {
|
||||
if (this.provider?.isNotSupportArrayContent) {
|
||||
return true
|
||||
}
|
||||
|
||||
const providers = ['deepseek', 'baichuan', 'minimax', 'xirang']
|
||||
|
||||
return providers.includes(this.provider.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the file content from the message
|
||||
* @param message - The message
|
||||
* @returns The file content
|
||||
*/
|
||||
private async extractFileContent(message: Message) {
|
||||
const fileBlocks = findFileBlocks(message)
|
||||
if (fileBlocks.length > 0) {
|
||||
const textFileBlocks = fileBlocks.filter(
|
||||
(fb) => fb.file && [FileTypes.TEXT, FileTypes.DOCUMENT].includes(fb.file.type)
|
||||
)
|
||||
|
||||
if (textFileBlocks.length > 0) {
|
||||
let text = ''
|
||||
const divider = '\n\n---\n\n'
|
||||
|
||||
for (const fileBlock of textFileBlocks) {
|
||||
const file = fileBlock.file
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileNameRow = 'file: ' + file.origin_name + '\n\n'
|
||||
text = text + fileNameRow + fileContent + divider
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message parameter
|
||||
* @param message - The message
|
||||
* @param model - The model
|
||||
* @returns The message parameter
|
||||
*/
|
||||
private async getMessageParam(
|
||||
message: Message,
|
||||
model: Model
|
||||
): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam> {
|
||||
const isVision = isVisionModel(model)
|
||||
const content = await this.getMessageContent(message)
|
||||
const fileBlocks = findFileBlocks(message)
|
||||
const imageBlocks = findImageBlocks(message)
|
||||
|
||||
if (fileBlocks.length === 0 && imageBlocks.length === 0) {
|
||||
return {
|
||||
role: message.role === 'system' ? 'user' : message.role,
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
// If the model does not support files, extract the file content
|
||||
if (this.isNotSupportFiles) {
|
||||
const fileContent = await this.extractFileContent(message)
|
||||
|
||||
return {
|
||||
role: message.role === 'system' ? 'user' : message.role,
|
||||
content: content + '\n\n---\n\n' + fileContent
|
||||
}
|
||||
}
|
||||
|
||||
// If the model supports files, add the file content to the message
|
||||
const parts: ChatCompletionContentPart[] = []
|
||||
|
||||
if (content) {
|
||||
parts.push({ type: 'text', text: content })
|
||||
}
|
||||
|
||||
for (const imageBlock of imageBlocks) {
|
||||
if (isVision) {
|
||||
if (imageBlock.file) {
|
||||
const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext)
|
||||
parts.push({ type: 'image_url', image_url: { url: image.data } })
|
||||
} else if (imageBlock.url && imageBlock.url.startsWith('data:')) {
|
||||
parts.push({ type: 'image_url', image_url: { url: imageBlock.url } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const fileBlock of fileBlocks) {
|
||||
const file = fileBlock.file
|
||||
if (!file) continue
|
||||
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role === 'system' ? 'user' : message.role,
|
||||
content: parts
|
||||
} as ChatCompletionMessageParam
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the temperature for the assistant
|
||||
* @param assistant - The assistant
|
||||
* @param model - The model
|
||||
* @returns The temperature
|
||||
*/
|
||||
private getTemperature(assistant: Assistant, model: Model) {
|
||||
return isReasoningModel(model) || isOpenAIWebSearch(model) ? undefined : assistant?.settings?.temperature
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provider specific parameters for the assistant
|
||||
* @param assistant - The assistant
|
||||
* @param model - The model
|
||||
* @returns The provider specific parameters
|
||||
*/
|
||||
private getProviderSpecificParameters(assistant: Assistant, model: Model) {
|
||||
const { maxTokens } = getAssistantSettings(assistant)
|
||||
|
||||
if (this.provider.id === 'openrouter') {
|
||||
if (model.id.includes('deepseek-r1')) {
|
||||
return {
|
||||
include_reasoning: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isOpenAIReasoning(model)) {
|
||||
return {
|
||||
max_tokens: undefined,
|
||||
max_completion_tokens: maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the top P for the assistant
|
||||
* @param assistant - The assistant
|
||||
* @param model - The model
|
||||
* @returns The top P
|
||||
*/
|
||||
private getTopP(assistant: Assistant, model: Model) {
|
||||
if (isReasoningModel(model) || isOpenAIWebSearch(model)) return undefined
|
||||
|
||||
return assistant?.settings?.topP
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reasoning effort for the assistant
|
||||
* @param assistant - The assistant
|
||||
* @param model - The model
|
||||
* @returns The reasoning effort
|
||||
*/
|
||||
private getReasoningEffort(assistant: Assistant, model: Model) {
|
||||
if (this.provider.id === 'groq') {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (isReasoningModel(model)) {
|
||||
if (model.provider === 'openrouter') {
|
||||
return {
|
||||
reasoning: {
|
||||
effort: assistant?.settings?.reasoning_effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isGrokReasoningModel(model)) {
|
||||
return {
|
||||
reasoning_effort: assistant?.settings?.reasoning_effort
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpenAIoSeries(model)) {
|
||||
return {
|
||||
reasoning_effort: assistant?.settings?.reasoning_effort
|
||||
}
|
||||
}
|
||||
|
||||
if (model.id.includes('claude-3.7-sonnet') || model.id.includes('claude-3-7-sonnet')) {
|
||||
const effortRatios: Record<ReasoningEffort, number> = {
|
||||
high: 0.8,
|
||||
medium: 0.5,
|
||||
low: 0.2
|
||||
}
|
||||
|
||||
const effort = assistant?.settings?.reasoning_effort as ReasoningEffort
|
||||
const effortRatio = effortRatios[effort]
|
||||
|
||||
if (!effortRatio) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const maxTokens = assistant?.settings?.maxTokens || DEFAULT_MAX_TOKENS
|
||||
const budgetTokens = Math.trunc(Math.max(Math.min(maxTokens * effortRatio, 32000), 1024))
|
||||
|
||||
return {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: budgetTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model is an OpenAI reasoning model
|
||||
* @param model - The model
|
||||
* @returns True if the model is an OpenAI reasoning model, false otherwise
|
||||
*/
|
||||
private isOpenAIReasoning(model: Model) {
|
||||
return model.id.startsWith('o1') || model.id.startsWith('o3')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate completions for the assistant
|
||||
* @param messages - The messages
|
||||
* @param assistant - The assistant
|
||||
* @param mcpTools - The MCP tools
|
||||
* @param onChunk - The onChunk callback
|
||||
* @param onFilterMessages - The onFilterMessages callback
|
||||
* @returns The completions
|
||||
*/
|
||||
async completions({ messages, assistant, mcpTools, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
|
||||
const defaultModel = getDefaultModel()
|
||||
const model = assistant.model || defaultModel
|
||||
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
|
||||
messages = addImageFileToContents(messages)
|
||||
let systemMessage = { role: 'system', content: assistant.prompt || '' }
|
||||
if (isOpenAIoSeries(model)) {
|
||||
systemMessage = {
|
||||
role: 'developer',
|
||||
content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}`
|
||||
}
|
||||
}
|
||||
if (mcpTools && mcpTools.length > 0) {
|
||||
systemMessage.content = buildSystemPrompt(systemMessage.content || '', mcpTools)
|
||||
}
|
||||
|
||||
const userMessages: ChatCompletionMessageParam[] = []
|
||||
const _messages = filterUserRoleStartMessages(
|
||||
filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 1)))
|
||||
)
|
||||
|
||||
onFilterMessages(_messages)
|
||||
|
||||
for (const message of _messages) {
|
||||
userMessages.push(await this.getMessageParam(message, model))
|
||||
}
|
||||
|
||||
const isOpenAIReasoning = this.isOpenAIReasoning(model)
|
||||
|
||||
const isSupportStreamOutput = () => {
|
||||
if (isOpenAIReasoning) {
|
||||
return false
|
||||
}
|
||||
return streamOutput
|
||||
}
|
||||
|
||||
let hasReasoningContent = false
|
||||
let lastChunk = ''
|
||||
const isReasoningJustDone = (
|
||||
delta: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta & {
|
||||
reasoning_content?: string
|
||||
reasoning?: string
|
||||
thinking?: string
|
||||
}
|
||||
) => {
|
||||
if (!delta?.content) return false
|
||||
|
||||
// 检查当前chunk和上一个chunk的组合是否形成###Response标记
|
||||
const combinedChunks = lastChunk + delta.content
|
||||
lastChunk = delta.content
|
||||
|
||||
// 检测思考结束
|
||||
if (combinedChunks.includes('###Response') || delta.content === '</think>') {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有reasoning_content或reasoning,说明是在思考中
|
||||
if (delta?.reasoning_content || delta?.reasoning || delta?.thinking) {
|
||||
hasReasoningContent = true
|
||||
}
|
||||
|
||||
// 如果之前有reasoning_content或reasoning,现在有普通content,说明思考结束
|
||||
if (hasReasoningContent && delta.content) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let time_first_token_millsec = 0
|
||||
let time_first_content_millsec = 0
|
||||
const start_time_millsec = new Date().getTime()
|
||||
const lastUserMessage = _messages.findLast((m) => m.role === 'user')
|
||||
const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true)
|
||||
const { signal } = abortController
|
||||
await this.checkIsCopilot()
|
||||
|
||||
const reqMessages: ChatCompletionMessageParam[] = [systemMessage, ...userMessages].filter(
|
||||
Boolean
|
||||
) as ChatCompletionMessageParam[]
|
||||
|
||||
const toolResponses: MCPToolResponse[] = []
|
||||
let firstChunk = true
|
||||
|
||||
const processToolUses = async (content: string, idx: number) => {
|
||||
const toolResults = await parseAndCallTools(
|
||||
content,
|
||||
toolResponses,
|
||||
onChunk,
|
||||
idx,
|
||||
mcpToolCallResponseToOpenAIMessage,
|
||||
mcpTools,
|
||||
isVisionModel(model)
|
||||
)
|
||||
|
||||
if (toolResults.length > 0) {
|
||||
reqMessages.push({
|
||||
role: 'assistant',
|
||||
content: content
|
||||
} as ChatCompletionMessageParam)
|
||||
toolResults.forEach((ts) => reqMessages.push(ts as ChatCompletionMessageParam))
|
||||
|
||||
const newStream = await this.sdk.chat.completions
|
||||
// @ts-ignore key is not typed
|
||||
.create(
|
||||
{
|
||||
model: model.id,
|
||||
messages: reqMessages,
|
||||
temperature: this.getTemperature(assistant, model),
|
||||
top_p: this.getTopP(assistant, model),
|
||||
max_tokens: maxTokens,
|
||||
keep_alive: this.keepAliveTime,
|
||||
stream: isSupportStreamOutput(),
|
||||
// tools: tools,
|
||||
...getOpenAIWebSearchParams(assistant, model),
|
||||
...this.getReasoningEffort(assistant, model),
|
||||
...this.getProviderSpecificParameters(assistant, model),
|
||||
...this.getCustomParameters(assistant)
|
||||
},
|
||||
{
|
||||
signal
|
||||
}
|
||||
)
|
||||
await processStream(newStream, idx + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const processStream = async (stream: any, idx: number) => {
|
||||
if (!isSupportStreamOutput()) {
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
return onChunk({
|
||||
text: stream.choices[0].message?.content || '',
|
||||
usage: stream.usage,
|
||||
metrics: {
|
||||
completion_tokens: stream.usage?.completion_tokens,
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let content = ''
|
||||
for await (const chunk of stream) {
|
||||
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
|
||||
break
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
content += delta.content
|
||||
}
|
||||
|
||||
if (delta?.reasoning_content || delta?.reasoning) {
|
||||
hasReasoningContent = true
|
||||
}
|
||||
|
||||
if (time_first_token_millsec == 0) {
|
||||
time_first_token_millsec = new Date().getTime() - start_time_millsec
|
||||
}
|
||||
|
||||
if (time_first_content_millsec == 0 && isReasoningJustDone(delta)) {
|
||||
time_first_content_millsec = new Date().getTime()
|
||||
}
|
||||
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0
|
||||
|
||||
// Extract citations from the raw response if available
|
||||
const citations = (chunk as OpenAI.Chat.Completions.ChatCompletionChunk & { citations?: string[] })?.citations
|
||||
|
||||
const finishReason = chunk.choices[0]?.finish_reason
|
||||
|
||||
let webSearch: any[] | undefined = undefined
|
||||
if (assistant.enableWebSearch && isZhipuModel(model) && finishReason === 'stop') {
|
||||
webSearch = chunk?.web_search
|
||||
}
|
||||
if (firstChunk && assistant.enableWebSearch && isHunyuanSearchModel(model)) {
|
||||
webSearch = chunk?.search_info?.search_results
|
||||
firstChunk = true
|
||||
}
|
||||
onChunk({
|
||||
text: delta?.content || '',
|
||||
reasoning_content: delta?.reasoning_content || delta?.reasoning || '',
|
||||
usage: chunk.usage,
|
||||
metrics: {
|
||||
completion_tokens: chunk.usage?.completion_tokens,
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec,
|
||||
time_thinking_millsec
|
||||
},
|
||||
webSearch,
|
||||
annotations: delta?.annotations,
|
||||
citations,
|
||||
mcpToolResponse: toolResponses
|
||||
})
|
||||
}
|
||||
|
||||
await processToolUses(content, idx)
|
||||
}
|
||||
|
||||
const stream = await this.sdk.chat.completions
|
||||
// @ts-ignore key is not typed
|
||||
.create(
|
||||
{
|
||||
model: model.id,
|
||||
messages: reqMessages,
|
||||
temperature: this.getTemperature(assistant, model),
|
||||
top_p: this.getTopP(assistant, model),
|
||||
max_tokens: maxTokens,
|
||||
keep_alive: this.keepAliveTime,
|
||||
stream: isSupportStreamOutput(),
|
||||
// tools: tools,
|
||||
...getOpenAIWebSearchParams(assistant, model),
|
||||
...this.getReasoningEffort(assistant, model),
|
||||
...this.getProviderSpecificParameters(assistant, model),
|
||||
...this.getCustomParameters(assistant)
|
||||
},
|
||||
{
|
||||
signal
|
||||
}
|
||||
)
|
||||
|
||||
await processStream(stream, 0).finally(cleanup)
|
||||
// 捕获signal的错误
|
||||
await signalPromise?.promise?.catch((error) => {
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a message
|
||||
* @param message - The message
|
||||
* @param assistant - The assistant
|
||||
* @param onResponse - The onResponse callback
|
||||
* @returns The translated message
|
||||
*/
|
||||
async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
|
||||
const defaultModel = getDefaultModel()
|
||||
const model = assistant.model || defaultModel
|
||||
const content = await this.getMessageContent(message)
|
||||
const messagesForApi = content
|
||||
? [
|
||||
{ role: 'system', content: assistant.prompt },
|
||||
{ role: 'user', content }
|
||||
]
|
||||
: [{ role: 'user', content: assistant.prompt }]
|
||||
|
||||
const isOpenAIReasoning = this.isOpenAIReasoning(model)
|
||||
|
||||
const isSupportedStreamOutput = () => {
|
||||
if (!onResponse) {
|
||||
return false
|
||||
}
|
||||
if (isOpenAIReasoning) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const stream = isSupportedStreamOutput()
|
||||
|
||||
await this.checkIsCopilot()
|
||||
|
||||
// @ts-ignore key is not typed
|
||||
const response = await this.sdk.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: messagesForApi as ChatCompletionMessageParam[],
|
||||
stream,
|
||||
keep_alive: this.keepAliveTime,
|
||||
temperature: assistant?.settings?.temperature
|
||||
})
|
||||
|
||||
if (!stream) {
|
||||
return response.choices[0].message?.content || ''
|
||||
}
|
||||
|
||||
let text = ''
|
||||
let isThinking = false
|
||||
const isReasoning = isReasoningModel(model)
|
||||
|
||||
for await (const chunk of response) {
|
||||
const deltaContent = chunk.choices[0]?.delta?.content || ''
|
||||
|
||||
if (isReasoning) {
|
||||
if (deltaContent.includes('<think>')) {
|
||||
isThinking = true
|
||||
}
|
||||
|
||||
if (!isThinking) {
|
||||
text += deltaContent
|
||||
onResponse?.(text)
|
||||
}
|
||||
|
||||
if (deltaContent.includes('</think>')) {
|
||||
isThinking = false
|
||||
}
|
||||
} else {
|
||||
text += deltaContent
|
||||
onResponse?.(text)
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize a message
|
||||
* @param messages - The messages
|
||||
* @param assistant - The assistant
|
||||
* @returns The summary
|
||||
*/
|
||||
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
|
||||
const model = getTopNamingModel() || assistant.model || getDefaultModel()
|
||||
|
||||
const userMessages = takeRight(messages, 5)
|
||||
.filter((message) => !message.isPreset)
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: getMessageContent(message)
|
||||
}))
|
||||
|
||||
const userMessageContent = userMessages.reduce((prev, curr) => {
|
||||
const content = curr.role === 'user' ? `User: ${curr.content}` : `Assistant: ${curr.content}`
|
||||
return prev + (prev ? '\n' : '') + content
|
||||
}, '')
|
||||
|
||||
const systemMessage = {
|
||||
role: 'system',
|
||||
content: getStoreSetting('topicNamingPrompt') || i18n.t('prompts.title')
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: userMessageContent
|
||||
}
|
||||
|
||||
await this.checkIsCopilot()
|
||||
|
||||
// @ts-ignore key is not typed
|
||||
const response = await this.sdk.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [systemMessage, userMessage] as ChatCompletionMessageParam[],
|
||||
stream: false,
|
||||
keep_alive: this.keepAliveTime,
|
||||
max_tokens: 1000
|
||||
})
|
||||
|
||||
// 针对思考类模型的返回,总结仅截取</think>之后的内容
|
||||
let content = response.choices[0].message?.content || ''
|
||||
content = content.replace(/^<think>(.*?)<\/think>/s, '')
|
||||
|
||||
return removeSpecialCharactersForTopicName(content.substring(0, 50))
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize a message for search
|
||||
* @param messages - The messages
|
||||
* @param assistant - The assistant
|
||||
* @returns The summary
|
||||
*/
|
||||
public async summaryForSearch(messages: Message[], assistant: Assistant): Promise<string | null> {
|
||||
const model = assistant.model || getDefaultModel()
|
||||
|
||||
const systemMessage = {
|
||||
role: 'system',
|
||||
content: assistant.prompt
|
||||
}
|
||||
|
||||
const messageContents = messages.map((m) => getMessageContent(m))
|
||||
const userMessageContent = messageContents.join('\n')
|
||||
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: userMessageContent
|
||||
}
|
||||
// @ts-ignore key is not typed
|
||||
const response = await this.sdk.chat.completions.create(
|
||||
{
|
||||
model: model.id,
|
||||
messages: [systemMessage, userMessage] as ChatCompletionMessageParam[],
|
||||
stream: false,
|
||||
keep_alive: this.keepAliveTime,
|
||||
max_tokens: 1000
|
||||
},
|
||||
{
|
||||
timeout: 20 * 1000
|
||||
}
|
||||
)
|
||||
|
||||
// 针对思考类模型的返回,总结仅截取</think>之后的内容
|
||||
let content = response.choices[0].message?.content || ''
|
||||
content = content.replace(/^<think>(.*?)<\/think>/s, '')
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text
|
||||
* @param prompt - The prompt
|
||||
* @param content - The content
|
||||
* @returns The generated text
|
||||
*/
|
||||
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
|
||||
const model = getDefaultModel()
|
||||
|
||||
await this.checkIsCopilot()
|
||||
|
||||
const response = await this.sdk.chat.completions.create({
|
||||
model: model.id,
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{ role: 'user', content }
|
||||
]
|
||||
})
|
||||
|
||||
return response.choices[0].message?.content || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate suggestions
|
||||
* @param messages - The messages
|
||||
* @param assistant - The assistant
|
||||
* @returns The suggestions
|
||||
*/
|
||||
async suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> {
|
||||
const model = assistant.model
|
||||
|
||||
if (!model) {
|
||||
return []
|
||||
}
|
||||
|
||||
await this.checkIsCopilot()
|
||||
|
||||
const userMessagesForApi = messages
|
||||
.filter((m) => m.role === 'user')
|
||||
.map((m) => ({
|
||||
role: m.role,
|
||||
content: getMessageContent(m)
|
||||
}))
|
||||
|
||||
const response: any = await this.sdk.request({
|
||||
method: 'post',
|
||||
path: '/advice_questions',
|
||||
body: {
|
||||
messages: userMessagesForApi,
|
||||
model: model.id,
|
||||
max_tokens: 0,
|
||||
temperature: 0,
|
||||
n: 0
|
||||
}
|
||||
})
|
||||
|
||||
return response?.questions?.filter(Boolean)?.map((q: any) => ({ content: q })) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model is valid
|
||||
* @param model - The model
|
||||
* @returns The validity of the model
|
||||
*/
|
||||
public async check(model: Model): Promise<{ valid: boolean; error: Error | null }> {
|
||||
if (!model) {
|
||||
return { valid: false, error: new Error('No model found') }
|
||||
}
|
||||
const body = {
|
||||
model: model.id,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: false
|
||||
}
|
||||
|
||||
try {
|
||||
await this.checkIsCopilot()
|
||||
const response = await this.sdk.chat.completions.create(body as ChatCompletionCreateParamsNonStreaming)
|
||||
|
||||
return {
|
||||
valid: Boolean(response?.choices[0].message),
|
||||
error: null
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
valid: false,
|
||||
error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the models
|
||||
* @returns The models
|
||||
*/
|
||||
public async models(): Promise<OpenAI.Models.Model[]> {
|
||||
try {
|
||||
await this.checkIsCopilot()
|
||||
|
||||
const response = await this.sdk.models.list()
|
||||
|
||||
if (this.provider.id === 'github') {
|
||||
// @ts-ignore key is not typed
|
||||
return response.body
|
||||
.map((model) => ({
|
||||
id: model.name,
|
||||
description: model.summary,
|
||||
object: 'model',
|
||||
owned_by: model.publisher
|
||||
}))
|
||||
.filter(isSupportedModel)
|
||||
}
|
||||
|
||||
if (this.provider.id === 'together') {
|
||||
// @ts-ignore key is not typed
|
||||
return response?.body
|
||||
.map((model: any) => ({
|
||||
id: model.id,
|
||||
description: model.display_name,
|
||||
object: 'model',
|
||||
owned_by: model.organization
|
||||
}))
|
||||
.filter(isSupportedModel)
|
||||
}
|
||||
|
||||
const models = response?.data || []
|
||||
|
||||
return models.filter(isSupportedModel)
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an image
|
||||
* @param params - The parameters
|
||||
* @returns The generated image
|
||||
*/
|
||||
public async generateImage({
|
||||
model,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
imageSize,
|
||||
batchSize,
|
||||
seed,
|
||||
numInferenceSteps,
|
||||
guidanceScale,
|
||||
signal,
|
||||
promptEnhancement
|
||||
}: GenerateImageParams): Promise<string[]> {
|
||||
const response = (await this.sdk.request({
|
||||
method: 'post',
|
||||
path: '/images/generations',
|
||||
signal,
|
||||
body: {
|
||||
model,
|
||||
prompt,
|
||||
negative_prompt: negativePrompt,
|
||||
image_size: imageSize,
|
||||
batch_size: batchSize,
|
||||
seed: seed ? parseInt(seed) : undefined,
|
||||
num_inference_steps: numInferenceSteps,
|
||||
guidance_scale: guidanceScale,
|
||||
prompt_enhancement: promptEnhancement
|
||||
}
|
||||
})) as { data: Array<{ url: string }> }
|
||||
|
||||
return response.data.map((item) => item.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the embedding dimensions
|
||||
* @param model - The model
|
||||
* @returns The embedding dimensions
|
||||
*/
|
||||
public async getEmbeddingDimensions(model: Model): Promise<number> {
|
||||
await this.checkIsCopilot()
|
||||
|
||||
const data = await this.sdk.embeddings.create({
|
||||
model: model.id,
|
||||
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi'
|
||||
})
|
||||
return data.data[0].embedding.length
|
||||
}
|
||||
|
||||
public async checkIsCopilot() {
|
||||
if (this.provider.id !== 'copilot') return
|
||||
const defaultHeaders = store.getState().copilot.defaultHeaders
|
||||
// copilot每次请求前需要重新获取token,因为token中附带时间戳
|
||||
const { token } = await window.api.copilot.getToken(defaultHeaders)
|
||||
this.sdk.apiKey = token
|
||||
}
|
||||
}
|
||||
98
electron.vite.config.1745374096634.mjs
Normal file
98
electron.vite.config.1745374096634.mjs
Normal file
@ -0,0 +1,98 @@
|
||||
// electron.vite.config.ts
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||
import { resolve } from "path";
|
||||
import { visualizer } from "rollup-plugin-visualizer";
|
||||
var visualizerPlugin = (type) => {
|
||||
return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : [];
|
||||
};
|
||||
var electron_vite_config_default = defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: [
|
||||
"@cherrystudio/embedjs",
|
||||
"@cherrystudio/embedjs-openai",
|
||||
"@cherrystudio/embedjs-loader-web",
|
||||
"@cherrystudio/embedjs-loader-markdown",
|
||||
"@cherrystudio/embedjs-loader-msoffice",
|
||||
"@cherrystudio/embedjs-loader-xml",
|
||||
"@cherrystudio/embedjs-loader-pdf",
|
||||
"@cherrystudio/embedjs-loader-sitemap",
|
||||
"@cherrystudio/embedjs-libsql",
|
||||
"@cherrystudio/embedjs-loader-image",
|
||||
"p-queue",
|
||||
"webdav"
|
||||
]
|
||||
}),
|
||||
...visualizerPlugin("main")
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@main": resolve("src/main"),
|
||||
"@types": resolve("src/renderer/src/types"),
|
||||
"@shared": resolve("packages/shared")
|
||||
}
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ["@libsql/client"]
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": resolve("packages/shared")
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: [
|
||||
[
|
||||
"styled-components",
|
||||
{
|
||||
displayName: true,
|
||||
// 开发环境下启用组件名称
|
||||
fileName: false,
|
||||
// 不在类名中包含文件名
|
||||
pure: true,
|
||||
// 优化性能
|
||||
ssr: false
|
||||
// 不需要服务端渲染
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}),
|
||||
...visualizerPlugin("renderer")
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@renderer": resolve("src/renderer/src"),
|
||||
"@shared": resolve("packages/shared")
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: []
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve("src/renderer/index.html")
|
||||
}
|
||||
},
|
||||
// 复制ASR服务器文件
|
||||
assetsInlineLimit: 0,
|
||||
// 确保复制assets目录下的所有文件
|
||||
copyPublicDir: true
|
||||
}
|
||||
}
|
||||
});
|
||||
export {
|
||||
electron_vite_config_default as default
|
||||
};
|
||||
14
package.json
14
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.2.5-bate",
|
||||
"version": "1.2.6-bate",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@ -44,7 +44,12 @@
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"check:i18n": "node scripts/check-i18n.js",
|
||||
"test": "npx -y tsx --test src/**/*.test.ts",
|
||||
"test": "yarn test:renderer",
|
||||
"test:coverage": "yarn test:renderer:coverage",
|
||||
"test:node": "npx -y tsx --test src/**/*.test.ts",
|
||||
"test:renderer": "vitest",
|
||||
"test:renderer:ui": "vitest --ui",
|
||||
"test:renderer:coverage": "vitest run --coverage",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
@ -186,6 +191,8 @@
|
||||
"@types/react-window": "^1",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/ui": "^3.1.1",
|
||||
"analytics": "^0.8.16",
|
||||
"antd": "^5.22.5",
|
||||
"applescript": "^1.0.0",
|
||||
@ -250,7 +257,8 @@
|
||||
"tokenx": "^0.4.1",
|
||||
"typescript": "^5.6.2",
|
||||
"uuid": "^10.0.0",
|
||||
"vite": "^5.0.12"
|
||||
"vite": "^5.0.12",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
|
||||
@ -7,6 +7,7 @@ import FileSystemServer from './filesystem'
|
||||
import MemoryServer from './memory'
|
||||
import ThinkingServer from './sequentialthinking'
|
||||
import SimpleRememberServer from './simpleremember'
|
||||
import TimeToolsServer from './timetools'
|
||||
import { WorkspaceFileToolServer } from './workspacefile'
|
||||
|
||||
export async function createInMemoryMCPServer(
|
||||
@ -62,6 +63,17 @@ export async function createInMemoryMCPServer(
|
||||
|
||||
return new WorkspaceFileToolServer(workspacePath).server
|
||||
}
|
||||
case '@cherry/timetools': {
|
||||
Logger.info('[MCP] Creating TimeToolsServer instance')
|
||||
try {
|
||||
const server = new TimeToolsServer().server
|
||||
Logger.info('[MCP] TimeToolsServer instance created successfully')
|
||||
return server
|
||||
} catch (error) {
|
||||
Logger.error('[MCP] Error creating TimeToolsServer instance:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
||||
}
|
||||
|
||||
208
src/main/mcpServers/timetools.ts
Normal file
208
src/main/mcpServers/timetools.ts
Normal file
@ -0,0 +1,208 @@
|
||||
// src/main/mcpServers/timetools.ts
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ErrorCode,
|
||||
ListToolsRequestSchema,
|
||||
McpError
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
// 定义时间工具
|
||||
const GET_CURRENT_TIME_TOOL = {
|
||||
name: 'get_current_time',
|
||||
description: '获取当前系统时间,返回格式化的日期和时间信息',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
title: 'GetCurrentTimeInput',
|
||||
description: '获取当前时间的输入参数',
|
||||
properties: {
|
||||
format: {
|
||||
type: 'string',
|
||||
description: '时间格式,可选值:full(完整格式)、date(仅日期)、time(仅时间)、iso(ISO格式),默认为full',
|
||||
enum: ['full', 'date', 'time', 'iso']
|
||||
},
|
||||
timezone: {
|
||||
type: 'string',
|
||||
description: '时区,例如:Asia/Shanghai,默认为系统本地时区'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 时间工具服务器类
|
||||
class TimeToolsServer {
|
||||
public server: Server
|
||||
|
||||
constructor() {
|
||||
Logger.info('[TimeTools] Creating server')
|
||||
|
||||
// 初始化服务器
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'time-tools-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {
|
||||
// 按照MCP规范声明工具能力
|
||||
listChanged: true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Logger.info('[TimeTools] Server initialized with tools capability')
|
||||
this.setupRequestHandlers()
|
||||
Logger.info('[TimeTools] Server initialization complete')
|
||||
}
|
||||
|
||||
// 设置请求处理程序
|
||||
setupRequestHandlers() {
|
||||
// 列出工具
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
Logger.info('[TimeTools] Listing tools request received')
|
||||
return {
|
||||
tools: [GET_CURRENT_TIME_TOOL]
|
||||
}
|
||||
})
|
||||
|
||||
// 处理工具调用
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
Logger.info(`[TimeTools] Tool call received: ${name}`, args)
|
||||
|
||||
try {
|
||||
if (name === 'get_current_time') {
|
||||
return this.handleGetCurrentTime(args)
|
||||
}
|
||||
|
||||
Logger.error(`[TimeTools] Unknown tool: ${name}`)
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`)
|
||||
} catch (error) {
|
||||
Logger.error(`[TimeTools] Error handling tool call ${name}:`, error)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 处理获取当前时间的工具调用
|
||||
private handleGetCurrentTime(args: any) {
|
||||
Logger.info('[TimeTools] Handling get_current_time', args)
|
||||
|
||||
const format = args?.format || 'full'
|
||||
const timezone = args?.timezone || undefined
|
||||
|
||||
const now = new Date()
|
||||
let formattedTime = ''
|
||||
|
||||
try {
|
||||
// 根据请求的格式返回时间
|
||||
switch (format) {
|
||||
case 'date':
|
||||
formattedTime = this.formatDate(now, timezone)
|
||||
break
|
||||
case 'time':
|
||||
formattedTime = this.formatTime(now, timezone)
|
||||
break
|
||||
case 'iso':
|
||||
formattedTime = now.toISOString()
|
||||
break
|
||||
case 'full':
|
||||
default:
|
||||
formattedTime = this.formatFull(now, timezone)
|
||||
break
|
||||
}
|
||||
|
||||
// 构建完整的响应对象
|
||||
const response = {
|
||||
currentTime: formattedTime,
|
||||
timestamp: now.getTime(),
|
||||
timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
format: format
|
||||
}
|
||||
|
||||
Logger.info('[TimeTools] Current time response:', response)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
],
|
||||
isError: false
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[TimeTools] Error formatting time:', error)
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Error formatting time: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化完整日期和时间
|
||||
private formatFull(date: Date, timezone?: string): string {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
timeZoneName: 'short'
|
||||
}
|
||||
|
||||
if (timezone) {
|
||||
options.timeZone = timezone
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', options).format(date)
|
||||
}
|
||||
|
||||
// 仅格式化日期
|
||||
private formatDate(date: Date, timezone?: string): string {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long'
|
||||
}
|
||||
|
||||
if (timezone) {
|
||||
options.timeZone = timezone
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', options).format(date)
|
||||
}
|
||||
|
||||
// 仅格式化时间
|
||||
private formatTime(date: Date, timezone?: string): string {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
timeZoneName: 'short'
|
||||
}
|
||||
|
||||
if (timezone) {
|
||||
options.timeZone = timezone
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', options).format(date)
|
||||
}
|
||||
}
|
||||
|
||||
export default TimeToolsServer
|
||||
@ -1,25 +1,28 @@
|
||||
import { AxiosInstance, default as axios_ } from 'axios'
|
||||
import { ProxyAgent } from 'proxy-agent'
|
||||
|
||||
import { proxyManager } from './ProxyManager'
|
||||
|
||||
class AxiosProxy {
|
||||
private cacheAxios: AxiosInstance | undefined
|
||||
private proxyURL: string | undefined
|
||||
private cacheAxios: AxiosInstance | null = null
|
||||
private proxyAgent: ProxyAgent | null = null
|
||||
|
||||
get axios(): AxiosInstance {
|
||||
const currentProxyURL = proxyManager.getProxyUrl()
|
||||
if (this.proxyURL !== currentProxyURL) {
|
||||
this.proxyURL = currentProxyURL
|
||||
const agent = proxyManager.getProxyAgent()
|
||||
// 获取当前代理代理
|
||||
const currentProxyAgent = proxyManager.getProxyAgent()
|
||||
|
||||
// 如果代理发生变化或尚未初始化,则重新创建 axios 实例
|
||||
if (this.cacheAxios === null || (currentProxyAgent !== null && this.proxyAgent !== currentProxyAgent)) {
|
||||
this.proxyAgent = currentProxyAgent
|
||||
|
||||
// 创建带有代理配置的 axios 实例
|
||||
this.cacheAxios = axios_.create({
|
||||
proxy: false,
|
||||
...(agent && { httpAgent: agent, httpsAgent: agent })
|
||||
httpAgent: currentProxyAgent || undefined,
|
||||
httpsAgent: currentProxyAgent || undefined
|
||||
})
|
||||
}
|
||||
|
||||
if (this.cacheAxios === undefined) {
|
||||
this.cacheAxios = axios_.create({ proxy: false })
|
||||
}
|
||||
return this.cacheAxios
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,29 +2,19 @@ import '@renderer/databases'
|
||||
|
||||
import store, { persistor } from '@renderer/store'
|
||||
import { Provider } from 'react-redux'
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
|
||||
import Sidebar from './components/app/Sidebar'
|
||||
import DeepClaudeProvider from './components/DeepClaudeProvider'
|
||||
import MemoryProvider from './components/MemoryProvider'
|
||||
import PDFSettingsInitializer from './components/PDFSettingsInitializer'
|
||||
import WebSearchInitializer from './components/WebSearchInitializer'
|
||||
import WorkspaceInitializer from './components/WorkspaceInitializer'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import StyleSheetManager from './context/StyleSheetManager'
|
||||
import { SyntaxHighlighterProvider } from './context/SyntaxHighlighterProvider'
|
||||
import { ThemeProvider } from './context/ThemeProvider'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AppsPage from './pages/apps/AppsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import PaintingsPage from './pages/paintings/PaintingsPage'
|
||||
import SettingsPage from './pages/settings/SettingsPage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
import WorkspacePage from './pages/workspace'
|
||||
import RouterComponent from './router/RouterConfig'
|
||||
|
||||
function App(): React.ReactElement {
|
||||
return (
|
||||
@ -37,23 +27,10 @@ function App(): React.ReactElement {
|
||||
<MemoryProvider>
|
||||
<DeepClaudeProvider />
|
||||
<PDFSettingsInitializer />
|
||||
<WebSearchInitializer />
|
||||
<WorkspaceInitializer />
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<NavigationHandler />
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings" element={<PaintingsPage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/workspace" element={<WorkspacePage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
<RouterComponent />
|
||||
</TopViewContainer>
|
||||
</MemoryProvider>
|
||||
</PersistGate>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useMemoryService } from '@renderer/services/MemoryService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import store from '@renderer/store'
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import {
|
||||
clearShortMemories,
|
||||
loadLongTermMemoryData,
|
||||
@ -43,14 +44,28 @@ const MemoryProvider: FC<MemoryProviderProps> = ({ children }) => {
|
||||
const analyzeModel = useAppSelector((state) => state.memory?.analyzeModel || null)
|
||||
const shortMemoryActive = useAppSelector((state) => state.memory?.shortMemoryActive || false)
|
||||
|
||||
// 获取当前对话
|
||||
const currentTopic = useAppSelector((state) => state.messages?.currentTopic?.id)
|
||||
const messages = useAppSelector((state) => {
|
||||
if (!currentTopic || !state.messages?.messagesByTopic) {
|
||||
return []
|
||||
// 创建记忆化选择器
|
||||
const selectCurrentTopicId = createSelector(
|
||||
[(state) => state.messages?.currentTopic?.id],
|
||||
(topicId) => topicId
|
||||
)
|
||||
|
||||
const selectMessagesForTopic = createSelector(
|
||||
[
|
||||
(state) => state.messages?.messagesByTopic,
|
||||
(_state, topicId) => topicId
|
||||
],
|
||||
(messagesByTopic, topicId) => {
|
||||
if (!topicId || !messagesByTopic) {
|
||||
return []
|
||||
}
|
||||
return messagesByTopic[topicId] || []
|
||||
}
|
||||
return state.messages.messagesByTopic[currentTopic] || []
|
||||
})
|
||||
)
|
||||
|
||||
// 获取当前对话
|
||||
const currentTopic = useAppSelector(selectCurrentTopicId)
|
||||
const messages = useAppSelector((state) => selectMessagesForTopic(state, currentTopic))
|
||||
|
||||
// 存储上一次的话题ID
|
||||
const previousTopicRef = useRef<string | null>(null)
|
||||
|
||||
@ -60,6 +60,9 @@ const WebviewContainer = memo(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appid, url])
|
||||
|
||||
//remove the tag of CherryStudio and Electron
|
||||
const userAgent = navigator.userAgent.replace(/CherryStudio\/\S+\s/, '').replace(/Electron\/\S+\s/, '')
|
||||
|
||||
return (
|
||||
<webview
|
||||
key={appid}
|
||||
@ -67,6 +70,7 @@ const WebviewContainer = memo(
|
||||
style={WebviewStyle}
|
||||
allowpopups={'true' as any}
|
||||
partition="persist:webview"
|
||||
useragent={userAgent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -131,6 +131,8 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
folder: ''
|
||||
})
|
||||
|
||||
// 是否手动编辑过标题
|
||||
const [hasTitleBeenManuallyEdited, setHasTitleBeenManuallyEdited] = useState(false)
|
||||
const [vaults, setVaults] = useState<Array<{ path: string; name: string }>>([])
|
||||
const [files, setFiles] = useState<FileInfo[]>([])
|
||||
const [fileTreeData, setFileTreeData] = useState<any[]>([])
|
||||
@ -255,6 +257,12 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
setState((prevState) => ({ ...prevState, [key]: value }))
|
||||
}
|
||||
|
||||
// 处理title输入变化
|
||||
const handleTitleInputChange = (newTitle: string) => {
|
||||
handleChange('title', newTitle)
|
||||
setHasTitleBeenManuallyEdited(true)
|
||||
}
|
||||
|
||||
const handleVaultChange = (value: string) => {
|
||||
setSelectedVault(value)
|
||||
// 文件夹会通过useEffect自动获取
|
||||
@ -278,11 +286,17 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
const fileName = selectedFile.name
|
||||
const titleWithoutExt = fileName.endsWith('.md') ? fileName.substring(0, fileName.length - 3) : fileName
|
||||
handleChange('title', titleWithoutExt)
|
||||
// 重置手动编辑标记,因为这是非用户设置的title
|
||||
setHasTitleBeenManuallyEdited(false)
|
||||
handleChange('processingMethod', '1')
|
||||
} else {
|
||||
// 如果是文件夹,自动设置标题为话题名并设置处理方式为3(新建)
|
||||
handleChange('processingMethod', '3')
|
||||
handleChange('title', title)
|
||||
// 仅当用户未手动编辑过 title 时,才将其重置为 props.title
|
||||
if (!hasTitleBeenManuallyEdited) {
|
||||
// title 是 props.title
|
||||
handleChange('title', title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -309,7 +323,7 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
|
||||
<Form.Item label={i18n.t('chat.topics.export.obsidian_title')}>
|
||||
<Input
|
||||
value={state.title}
|
||||
onChange={(e) => handleChange('title', e.target.value)}
|
||||
onChange={(e) => handleTitleInputChange(e.target.value)}
|
||||
placeholder={i18n.t('chat.topics.export.obsidian_title_placeholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
20
src/renderer/src/components/WebSearchInitializer.tsx
Normal file
20
src/renderer/src/components/WebSearchInitializer.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
|
||||
/**
|
||||
* 初始化WebSearch服务的组件
|
||||
* 确保DeepSearch供应商被添加到列表中
|
||||
*/
|
||||
const WebSearchInitializer = () => {
|
||||
useEffect(() => {
|
||||
// 触发WebSearchService的初始化
|
||||
// 这将确保DeepSearch供应商被添加到列表中
|
||||
WebSearchService.getWebSearchProvider()
|
||||
console.log('[WebSearchInitializer] 初始化WebSearch服务')
|
||||
}, [])
|
||||
|
||||
// 这个组件不渲染任何内容
|
||||
return null
|
||||
}
|
||||
|
||||
export default WebSearchInitializer
|
||||
@ -25,7 +25,6 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { useTheme } from '../../hooks/useTheme'
|
||||
|
||||
const { Title } = Typography
|
||||
const { TabPane } = Tabs
|
||||
|
||||
// --- Styled Components Props Interfaces ---
|
||||
|
||||
@ -308,48 +307,56 @@ const WorkspaceFileViewer: React.FC<FileViewerProps> = ({
|
||||
</FileHeader>
|
||||
|
||||
<FileContent>
|
||||
<FullHeightTabs defaultActiveKey="code">
|
||||
{/* 代码 Tab: 使用 SyntaxHighlighter 或 TextArea */}
|
||||
<TabPane tab={t('workspace.code')} key="code">
|
||||
<CodeScrollContainer>
|
||||
{isEditing ? (
|
||||
<EditorTextArea
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
isDark={!useInternalLightTheme}
|
||||
ref={textAreaRef}
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={syntaxHighlighterStyle} // 应用所选主题
|
||||
showLineNumbers
|
||||
wrapLines={true}
|
||||
lineProps={{ style: { wordBreak: 'break-all', whiteSpace: 'pre-wrap' } }}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '16px',
|
||||
borderRadius: 0,
|
||||
minHeight: '100%',
|
||||
fontSize: token.fontSizeSM // 可以用小号字体
|
||||
// 背景色由 style prop 的主题决定
|
||||
}}
|
||||
codeTagProps={{ style: { display: 'block', fontFamily: token.fontFamilyCode } }} // 明确使用代码字体
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</CodeScrollContainer>
|
||||
</TabPane>
|
||||
|
||||
{/* 原始内容 Tab: 只使用 RawScrollContainer 显示纯文本 */}
|
||||
<TabPane tab={t('workspace.raw')} key="raw">
|
||||
<RawScrollContainer isDark={isDarkThemeForRaw} token={token}>
|
||||
{content} {/* 直接渲染文本内容,没有 SyntaxHighlighter */}
|
||||
</RawScrollContainer>
|
||||
</TabPane>
|
||||
</FullHeightTabs>
|
||||
<FullHeightTabs
|
||||
defaultActiveKey="code"
|
||||
items={[
|
||||
{
|
||||
key: 'code',
|
||||
label: t('workspace.code'),
|
||||
children: (
|
||||
<CodeScrollContainer>
|
||||
{isEditing ? (
|
||||
<EditorTextArea
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
isDark={!useInternalLightTheme}
|
||||
ref={textAreaRef}
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={syntaxHighlighterStyle} // 应用所选主题
|
||||
showLineNumbers
|
||||
wrapLines={true}
|
||||
lineProps={{ style: { wordBreak: 'break-all', whiteSpace: 'pre-wrap' } }}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '16px',
|
||||
borderRadius: 0,
|
||||
minHeight: '100%',
|
||||
fontSize: token.fontSizeSM // 可以用小号字体
|
||||
// 背景色由 style prop 的主题决定
|
||||
}}
|
||||
codeTagProps={{ style: { display: 'block', fontFamily: token.fontFamilyCode } }} // 明确使用代码字体
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</CodeScrollContainer>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'raw',
|
||||
label: t('workspace.raw'),
|
||||
children: (
|
||||
<RawScrollContainer isDark={isDarkThemeForRaw} token={token}>
|
||||
{content} {/* 直接渲染文本内容,没有 SyntaxHighlighter */}
|
||||
</RawScrollContainer>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</FileContent>
|
||||
|
||||
<ActionBar token={token}>
|
||||
|
||||
@ -1673,34 +1673,28 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
],
|
||||
openrouter: [
|
||||
{
|
||||
id: 'google/gemma-2-9b-it:free',
|
||||
id: 'google/gemini-2.5-flash-preview',
|
||||
provider: 'openrouter',
|
||||
name: 'Google: Gemma 2 9B',
|
||||
group: 'Gemma'
|
||||
name: 'Google: Gemini 2.5 Flash Preview',
|
||||
group: 'google'
|
||||
},
|
||||
{
|
||||
id: 'microsoft/phi-3-mini-128k-instruct:free',
|
||||
id: 'qwen/qwen-2.5-7b-instruct:free',
|
||||
provider: 'openrouter',
|
||||
name: 'Phi-3 Mini 128K Instruct',
|
||||
group: 'Phi'
|
||||
name: 'Qwen: Qwen-2.5-7B Instruct',
|
||||
group: 'qwen'
|
||||
},
|
||||
{
|
||||
id: 'microsoft/phi-3-medium-128k-instruct:free',
|
||||
id: 'deepseek/deepseek-chat',
|
||||
provider: 'openrouter',
|
||||
name: 'Phi-3 Medium 128K Instruct',
|
||||
group: 'Phi'
|
||||
},
|
||||
{
|
||||
id: 'meta-llama/llama-3-8b-instruct:free',
|
||||
provider: 'openrouter',
|
||||
name: 'Meta: Llama 3 8B Instruct',
|
||||
group: 'Llama3'
|
||||
name: 'DeepSeek: V3',
|
||||
group: 'deepseek'
|
||||
},
|
||||
{
|
||||
id: 'mistralai/mistral-7b-instruct:free',
|
||||
provider: 'openrouter',
|
||||
name: 'Mistral: Mistral 7B Instruct',
|
||||
group: 'Mistral'
|
||||
group: 'mistralai'
|
||||
}
|
||||
],
|
||||
groq: [
|
||||
|
||||
@ -117,6 +117,11 @@ export const SEARCH_SUMMARY_PROMPT = `
|
||||
If the user asks some question from some URL or wants you to summarize a PDF or a webpage (via URL) you need to return the links inside the \`links\` XML block and the question inside the \`question\` XML block. If the user wants to you to summarize the webpage or the PDF you need to return \`summarize\` inside the \`question\` XML block in place of a question and the link to summarize in the \`links\` XML block.
|
||||
You must always return the rephrased question inside the \`question\` XML block, if there are no links in the follow-up question then don't insert a \`links\` XML block in your response.
|
||||
|
||||
4. Websearch: Always return the rephrased question inside the 'question' XML block. If there are no links in the follow-up question, do not insert a 'links' XML block in your response.
|
||||
5. Knowledge: Always return the rephrased question inside the 'question' XML block.
|
||||
6. Always wrap the rephrased question in the appropriate XML blocks to specify the tool(s) for retrieving information: use <websearch></websearch> for queries requiring real-time or external information, <knowledge></knowledge> for queries that can be answered from a pre-existing knowledge base, or both if the question could be applicable to either tool. Ensure that the rephrased question is always contained within a <question></question> block inside these wrappers.
|
||||
7. *use {tools} to rephrase the question*
|
||||
|
||||
There are several examples attached for your reference inside the below \`examples\` XML block
|
||||
|
||||
<examples>
|
||||
|
||||
@ -1881,6 +1881,7 @@
|
||||
},
|
||||
"error.failed": "Translation failed",
|
||||
"error.not_configured": "Translation model is not configured",
|
||||
"success": "Translation successful",
|
||||
"history": {
|
||||
"clear": "Clear History",
|
||||
"clear_description": "Clear history will delete all translation history, continue?",
|
||||
|
||||
@ -1988,6 +1988,7 @@
|
||||
},
|
||||
"error.failed": "翻译失败",
|
||||
"error.not_configured": "翻译模型未配置",
|
||||
"success": "翻译成功",
|
||||
"history": {
|
||||
"clear": "清空历史",
|
||||
"clear_description": "清空历史将删除所有翻译历史记录,是否继续?",
|
||||
|
||||
@ -26,12 +26,21 @@ interface Props {
|
||||
setFiles: (files: FileType[]) => void
|
||||
}
|
||||
|
||||
const MAX_FILENAME_DISPLAY_LENGTH = 20
|
||||
function truncateFileName(name: string, maxLength: number = MAX_FILENAME_DISPLAY_LENGTH) {
|
||||
if (name.length <= maxLength) return name
|
||||
return name.slice(0, maxLength - 3) + '...'
|
||||
}
|
||||
|
||||
const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
const isImage = (ext: string) => {
|
||||
return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext)
|
||||
}
|
||||
|
||||
const fullName = FileManager.formatFileName(file)
|
||||
const displayName = truncateFileName(fullName)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
styles={{
|
||||
@ -53,6 +62,7 @@ const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<FileNameSpan>{fullName}</FileNameSpan>
|
||||
{formatFileSize(file.size)}
|
||||
</Flex>
|
||||
}>
|
||||
@ -66,8 +76,9 @@ const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
|
||||
if (path) {
|
||||
window.api.file.openPath(path)
|
||||
}
|
||||
}}>
|
||||
{FileManager.formatFileName(file)}
|
||||
}}
|
||||
title={fullName}>
|
||||
{displayName}
|
||||
</FileName>
|
||||
</Tooltip>
|
||||
)
|
||||
@ -157,4 +168,8 @@ const FileName = styled.span`
|
||||
}
|
||||
`
|
||||
|
||||
const FileNameSpan = styled.span`
|
||||
word-break: break-all;
|
||||
`
|
||||
|
||||
export default AttachmentPreview
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'katex/dist/contrib/copy-tex'
|
||||
import 'katex/dist/contrib/mhchem'
|
||||
import '@renderer/styles/translation.css'
|
||||
|
||||
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@ -38,6 +39,11 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
const { renderInputMessageAsMarkdown, mathEngine } = useSettings()
|
||||
|
||||
const messageContent = useMemo(() => {
|
||||
// 检查消息内容是否为空或未定义
|
||||
if (message.content === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const empty = isEmpty(message.content)
|
||||
const paused = message.status === 'paused'
|
||||
const content = empty && paused ? t('message.chat.completion.paused') : withGeminiGrounding(message)
|
||||
@ -82,6 +88,19 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
// 自定义处理translated标签
|
||||
translated: (props: any) => {
|
||||
// 将translated标签渲染为可点击的span
|
||||
return (
|
||||
<span
|
||||
className="translated-text"
|
||||
onClick={(e) => window.toggleTranslation(e as unknown as MouseEvent)}
|
||||
data-original={props.original}
|
||||
data-language={props.language}>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// Removed custom div renderer for tool markers
|
||||
} as Partial<Components> // Keep Components type here
|
||||
@ -89,7 +108,7 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
}, []) // Removed message.metadata dependency as it's no longer used here
|
||||
|
||||
if (message.role === 'user' && !renderInputMessageAsMarkdown) {
|
||||
return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>
|
||||
return <p className="user-message-content">{messageContent}</p>
|
||||
}
|
||||
|
||||
if (processedMessageContent.includes('<style>')) {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import TTSProgressBar from '@renderer/components/TTSProgressBar'
|
||||
import { FONT_FAMILY } from '@renderer/config/constant'
|
||||
import { TranslateLanguageOptions } from '@renderer/config/translate'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
|
||||
import { useModel } from '@renderer/hooks/useModel'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
@ -20,6 +22,13 @@ import { shallowEqual } from 'react-redux'
|
||||
// import { useSelector } from 'react-redux'; // Removed unused import
|
||||
import styled from 'styled-components' // Ensure styled-components is imported
|
||||
|
||||
// 扩展Window接口
|
||||
declare global {
|
||||
interface Window {
|
||||
toggleTranslation: (event: MouseEvent) => void
|
||||
}
|
||||
}
|
||||
|
||||
import MessageContent from './MessageContent'
|
||||
import MessageErrorBoundary from './MessageErrorBoundary'
|
||||
import MessageHeader from './MessageHeader'
|
||||
@ -341,6 +350,171 @@ const MessageItem: FC<Props> = ({
|
||||
|
||||
// 使用 hook 封装上下文菜单项生成逻辑,便于在组件内使用
|
||||
const useContextMenuItems = (t: (key: string) => string, message: Message) => {
|
||||
// 使用useAppSelector获取话题对象
|
||||
const topicObj = useAppSelector((state) => {
|
||||
const assistants = state.assistants.assistants
|
||||
for (const assistant of assistants) {
|
||||
const topic = assistant.topics.find((t) => t.id === message.topicId)
|
||||
if (topic) return topic
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 如果找不到话题对象,创建一个简单的话题对象
|
||||
const fallbackTopic = useMemo(
|
||||
() => ({
|
||||
id: message.topicId,
|
||||
assistantId: message.assistantId,
|
||||
name: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
messages: []
|
||||
}),
|
||||
[message.topicId, message.assistantId]
|
||||
)
|
||||
|
||||
// 导入翻译相关的依赖
|
||||
const { editMessage } = useMessageOperations(topicObj || fallbackTopic)
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
|
||||
// 不再需要存储翻译映射关系
|
||||
|
||||
// 处理翻译功能
|
||||
const handleTranslate = useCallback(
|
||||
async (language: string, text: string, selection?: { start: number; end: number }) => {
|
||||
if (isTranslating) return
|
||||
|
||||
// 显示翻译中的提示
|
||||
window.message.loading({ content: t('translate.processing'), key: 'translate-message' })
|
||||
|
||||
setIsTranslating(true)
|
||||
|
||||
try {
|
||||
// 导入翻译服务
|
||||
const { translateText } = await import('@renderer/services/TranslateService')
|
||||
|
||||
// 检查文本是否包含翻译标签
|
||||
const translatedTagRegex = /<translated[^>]*>([\s\S]*?)<\/translated>/g
|
||||
|
||||
// 如果文本包含翻译标签,则提取原始文本
|
||||
let originalText = text
|
||||
const translatedMatch = text.match(translatedTagRegex)
|
||||
if (translatedMatch) {
|
||||
// 提取原始文本属性
|
||||
const originalAttrRegex = /original="([^"]*)"/
|
||||
const originalAttrMatch = translatedMatch[0].match(originalAttrRegex)
|
||||
if (originalAttrMatch && originalAttrMatch[1]) {
|
||||
originalText = originalAttrMatch[1].replace(/"/g, '"')
|
||||
}
|
||||
}
|
||||
|
||||
// 执行翻译
|
||||
const translatedText = await translateText(originalText, language)
|
||||
|
||||
// 如果是选中的文本,直接替换原文中的选中部分
|
||||
if (selection) {
|
||||
// 不再需要存储翻译映射关系
|
||||
|
||||
// 替换消息内容中的选中部分
|
||||
const newContent =
|
||||
message.content.substring(0, selection.start) +
|
||||
`<translated original="${originalText.replace(/"/g, '"')}" language="${language}">${translatedText}</translated>` +
|
||||
message.content.substring(selection.end)
|
||||
|
||||
// 更新消息内容
|
||||
editMessage(message.id, { content: newContent })
|
||||
|
||||
// 关闭加载提示
|
||||
window.message.destroy('translate-message')
|
||||
|
||||
// 显示成功提示
|
||||
window.message.success({
|
||||
content: t('translate.success'),
|
||||
key: 'translate-message'
|
||||
})
|
||||
}
|
||||
// 如果是整个消息的翻译,则更新消息的翻译内容
|
||||
else if (text === message.content) {
|
||||
// 更新消息的翻译内容
|
||||
editMessage(message.id, { translatedContent: translatedText })
|
||||
|
||||
// 关闭加载提示
|
||||
window.message.destroy('translate-message')
|
||||
|
||||
// 显示成功提示
|
||||
window.message.success({
|
||||
content: t('translate.success'),
|
||||
key: 'translate-message'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Translation failed:', error)
|
||||
window.message.error({ content: t('translate.error.failed'), key: 'translate-message' })
|
||||
} finally {
|
||||
setIsTranslating(false)
|
||||
}
|
||||
},
|
||||
[isTranslating, message, editMessage, t]
|
||||
)
|
||||
|
||||
// 添加全局翻译切换函数
|
||||
useEffect(() => {
|
||||
// 定义切换翻译的函数
|
||||
window.toggleTranslation = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.classList.contains('translated-text')) {
|
||||
const original = target.getAttribute('data-original')
|
||||
const currentText = target.textContent
|
||||
|
||||
// 切换显示内容
|
||||
if (target.getAttribute('data-showing-original') === 'true') {
|
||||
// 当前显示原文,切换回翻译文本
|
||||
target.textContent = target.getAttribute('data-translated') || ''
|
||||
target.setAttribute('data-showing-original', 'false')
|
||||
} else {
|
||||
// 当前显示翻译文本,切换回原文
|
||||
// 始终保存当前翻译文本,不论翻译多少次
|
||||
if (!target.hasAttribute('data-translated')) {
|
||||
target.setAttribute('data-translated', currentText || '')
|
||||
}
|
||||
target.textContent = original || ''
|
||||
target.setAttribute('data-showing-original', 'true')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
// 使用类型断言来避免 TypeScript 错误
|
||||
;(window as any).toggleTranslation = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 获取选中文本的位置信息
|
||||
const getSelectionInfo = useCallback(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return null
|
||||
|
||||
// 获取消息内容
|
||||
const content = message.content
|
||||
|
||||
// 获取选中文本
|
||||
const selectedText = selection.toString()
|
||||
|
||||
// 如果没有选中文本,返回null
|
||||
if (!selectedText) return null
|
||||
|
||||
// 尝试获取选中文本在消息内容中的位置
|
||||
const startIndex = content.indexOf(selectedText)
|
||||
if (startIndex === -1) return null
|
||||
|
||||
return {
|
||||
text: selectedText,
|
||||
start: startIndex,
|
||||
end: startIndex + selectedText.length
|
||||
}
|
||||
}, [message.content])
|
||||
|
||||
return useMemo(() => {
|
||||
return (selectedQuoteText: string, selectedText: string): ItemType[] => {
|
||||
const items: ItemType[] = []
|
||||
@ -363,6 +537,27 @@ const useContextMenuItems = (t: (key: string) => string, message: Message) => {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加翻译子菜单
|
||||
items.push({
|
||||
key: 'translate',
|
||||
label: t('chat.translate') || '翻译',
|
||||
children: [
|
||||
...TranslateLanguageOptions.map((item) => ({
|
||||
label: item.emoji + ' ' + item.label,
|
||||
key: `translate-${item.value}`,
|
||||
onClick: () => {
|
||||
const selectionInfo = getSelectionInfo()
|
||||
if (selectionInfo) {
|
||||
handleTranslate(item.value, selectedText, selectionInfo)
|
||||
} else {
|
||||
handleTranslate(item.value, selectedText)
|
||||
}
|
||||
}
|
||||
}))
|
||||
]
|
||||
})
|
||||
|
||||
items.push({
|
||||
key: 'speak_selected',
|
||||
label: t('chat.message.speak_selection') || '朗读选中部分',
|
||||
@ -406,7 +601,7 @@ const useContextMenuItems = (t: (key: string) => string, message: Message) => {
|
||||
|
||||
return items
|
||||
}
|
||||
}, [t, message.id, message.content]) // 只依赖 t 和 message 的关键属性
|
||||
}, [t, message.id, message.content, handleTranslate, getSelectionInfo]) // 添加getSelectionInfo到依赖项
|
||||
}
|
||||
|
||||
// Styled components definitions
|
||||
@ -446,6 +641,8 @@ const MessageContentContainer = styled.div`
|
||||
margin-top: 5px;
|
||||
`
|
||||
|
||||
// 样式已移至Markdown组件中处理
|
||||
|
||||
const MessageFooter = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@ -214,7 +214,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
onClick={() => scrollToMessage(message)}>
|
||||
<MessageItemContainer style={{ transform: ` scale(${scale})` }}>
|
||||
<MessageItemTitle>{username}</MessageItemTitle>
|
||||
<MessageItemContent>{message.content.substring(0, 50)}</MessageItemContent>
|
||||
<MessageItemContent>{message.content ? message.content.substring(0, 50) : ''}</MessageItemContent>
|
||||
</MessageItemContainer>
|
||||
|
||||
{message.role === 'assistant' ? (
|
||||
|
||||
@ -52,6 +52,17 @@ const MessageAttachments: FC<Props> = ({ message }) => {
|
||||
})
|
||||
}, [nonImageFiles])
|
||||
|
||||
const StyledUpload = styled(Upload)`
|
||||
.ant-upload-list-item-name {
|
||||
max-width: 220px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
`
|
||||
|
||||
return (
|
||||
<Container style={{ marginBottom: 8 }}>
|
||||
{/* 渲染图片文件 */}
|
||||
@ -99,7 +110,7 @@ const MessageAttachments: FC<Props> = ({ message }) => {
|
||||
{/* 渲染非图片文件 */}
|
||||
{nonImageFiles.length > 0 && (
|
||||
<FileContainer className="message-attachments">
|
||||
<Upload listType="text" disabled fileList={memoizedFileList} />
|
||||
<StyledUpload listType="text" disabled fileList={memoizedFileList} />
|
||||
</FileContainer>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
@ -169,6 +169,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
// Convert [n] format to superscript numbers and make them clickable
|
||||
// Use <sup> tag for superscript and make it a link with citation data
|
||||
if (message.metadata?.webSearch || message.metadata?.knowledge) {
|
||||
// 修复引用bug,支持[[1]]和[1]两种格式
|
||||
content = content.replace(/\[\[(\d+)\]\]|\[(\d+)\]/g, (match, num1, num2) => {
|
||||
const num = num1 || num2
|
||||
const index = parseInt(num) - 1
|
||||
@ -229,9 +230,11 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
}
|
||||
|
||||
// --- MODIFIED LINE BELOW ---
|
||||
// This regex now matches <tool_use ...> OR <XML ...> tags (case-insensitive)
|
||||
// and allows for attributes and whitespace, then removes the entire tag pair and content.
|
||||
const tagsToRemoveRegex = /<(?:tool_use|XML)(?:[^>]*)?>(?:.*?)<\/\s*(?:tool_use|XML)\s*>/gis
|
||||
// This regex matches various tool calling formats:
|
||||
// 1. <tool_use>...</tool_use> - Standard format
|
||||
// 2. Special format: <tool_use>feaAumUH6sCQu074KDtuY6{"format": "time"}</tool_use>
|
||||
// Case-insensitive, allows for attributes and whitespace
|
||||
const tagsToRemoveRegex = /<tool_use>(?:[\s\S]*?)<\/tool_use>/gi
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@ -338,8 +341,8 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
// Apply regex replacement here for TTS
|
||||
<TTSHighlightedText text={processedContent.replace(tagsToRemoveRegex, '')} />
|
||||
) : (
|
||||
// Don't remove XML tags, let Markdown component handle them
|
||||
<Markdown message={{ ...message, content: processedContent }} />
|
||||
// Remove tool_use XML tags before rendering Markdown
|
||||
<Markdown message={{ ...message, content: processedContent.replace(tagsToRemoveRegex, '') }} />
|
||||
)}
|
||||
{message.metadata?.generateImage && <MessageImage message={message} />}
|
||||
{message.translatedContent && (
|
||||
|
||||
@ -13,7 +13,7 @@ import { formatFileSize } from '@renderer/utils'
|
||||
import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant'
|
||||
import { Alert, Button, Dropdown, Empty, message, Tag, Tooltip, Upload } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronsDown, ChevronsUp, Plus, Settings2 } from 'lucide-react'
|
||||
import { ChevronsDown, ChevronsUp, Plus, Search, Settings2 } from 'lucide-react'
|
||||
import VirtualList from 'rc-virtual-list'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -21,6 +21,8 @@ import styled from 'styled-components'
|
||||
|
||||
import CustomCollapse from '../../components/CustomCollapse'
|
||||
import FileItem from '../files/FileItem'
|
||||
import { NavbarIcon } from '../home/Navbar'
|
||||
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
|
||||
import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup'
|
||||
import StatusIcon from './components/StatusIcon'
|
||||
|
||||
@ -248,6 +250,10 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
</div>
|
||||
</ModelInfo>
|
||||
<HStack gap={8} alignItems="center">
|
||||
{/* 使用selected base导致修改设置后没有响应式更新 */}
|
||||
<NarrowIcon onClick={() => base && KnowledgeSearchPopup.show({base: base})}>
|
||||
<Search size={18}/>
|
||||
</NarrowIcon>
|
||||
<Tooltip title={expandAll ? t('common.collapse') : t('common.expand')}>
|
||||
<Button
|
||||
size="small"
|
||||
@ -694,4 +700,10 @@ const RefreshIcon = styled(RedoOutlined)`
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
const NarrowIcon = styled(NavbarIcon)`
|
||||
@media (max-width: 1000px) {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default KnowledgeContent
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import { DeleteOutlined, EditOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
// import { HStack } from '@renderer/components/Layout'
|
||||
import ListItem from '@renderer/components/ListItem'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { NavbarIcon } from '@renderer/pages/home/Navbar'
|
||||
// import { NavbarIcon } from '@renderer/pages/home/Navbar'
|
||||
import KnowledgeSearchPopup from '@renderer/pages/knowledge/components/KnowledgeSearchPopup'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { Dropdown, Empty, MenuProps } from 'antd'
|
||||
import { Book, Plus, Search } from 'lucide-react'
|
||||
import { Book, Plus } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@ -96,13 +96,6 @@ const KnowledgePage: FC = () => {
|
||||
<Container>
|
||||
<Navbar>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('knowledge.title')}</NavbarCenter>
|
||||
<NavbarRight>
|
||||
<HStack alignItems="center">
|
||||
<NarrowIcon onClick={() => selectedBase && KnowledgeSearchPopup.show({ base: selectedBase })}>
|
||||
<Search size={18} />
|
||||
</NarrowIcon>
|
||||
</HStack>
|
||||
</NavbarRight>
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
<SideNav>
|
||||
@ -243,10 +236,4 @@ const AddKnowledgeName = styled.div`
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const NarrowIcon = styled(NavbarIcon)`
|
||||
@media (max-width: 1000px) {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default KnowledgePage
|
||||
|
||||
@ -204,18 +204,18 @@ const DataSettings: FC = () => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.app_data')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px">
|
||||
<Typography.Text style={{ color: 'var(--color-text-3)' }}>{appInfo?.appDataPath}</Typography.Text>
|
||||
<StyledIcon onClick={() => handleOpenPath(appInfo?.appDataPath)} />
|
||||
</HStack>
|
||||
<PathRow>
|
||||
<PathText style={{ color: 'var(--color-text-3)' }}>{appInfo?.appDataPath}</PathText>
|
||||
<StyledIcon onClick={() => handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} />
|
||||
</PathRow>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.app_logs')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px">
|
||||
<Typography.Text style={{ color: 'var(--color-text-3)' }}>{appInfo?.logsPath}</Typography.Text>
|
||||
<StyledIcon onClick={() => handleOpenPath(appInfo?.logsPath)} />
|
||||
</HStack>
|
||||
<PathRow>
|
||||
<PathText style={{ color: 'var(--color-text-3)' }}>{appInfo?.logsPath}</PathText>
|
||||
<StyledIcon onClick={() => handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} />
|
||||
</PathRow>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
@ -280,4 +280,24 @@ const MenuList = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const PathText = styled(Typography.Text)`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
text-align: right;
|
||||
margin-left: 5px;
|
||||
`
|
||||
|
||||
const PathRow = styled(HStack)`
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
`
|
||||
|
||||
export default DataSettings
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setUsePromptForToolCalling } from '@renderer/store/settings'
|
||||
import { Switch, Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
|
||||
const McpToolCallingSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { usePromptForToolCalling } = useSettings()
|
||||
|
||||
return (
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.mcp.tool_calling.title', '工具调用设置')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.mcp.tool_calling.use_prompt', '使用提示词调用工具')}
|
||||
<Tooltip
|
||||
title={t(
|
||||
'settings.mcp.tool_calling.use_prompt_tooltip',
|
||||
'启用后,将使用提示词而非函数调用来调用MCP工具。适用于所有模型,但可能不如函数调用精确。'
|
||||
)}
|
||||
placement="right">
|
||||
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
|
||||
</Tooltip>
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={usePromptForToolCalling}
|
||||
onChange={(checked) => dispatch(setUsePromptForToolCalling(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<Description>
|
||||
{t(
|
||||
'settings.mcp.tool_calling.description',
|
||||
'提示词调用工具:适用于所有模型,但可能不如函数调用精确。\n函数调用工具:仅适用于支持函数调用的模型,但调用更精确。'
|
||||
)}
|
||||
</Description>
|
||||
</SettingGroup>
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const Description = styled.div`
|
||||
color: var(--color-text-3);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin-top: 8px;
|
||||
white-space: pre-line;
|
||||
`
|
||||
|
||||
export default McpToolCallingSettings
|
||||
@ -2,6 +2,7 @@ import { ArrowLeftOutlined, CodeOutlined, PlusOutlined } from '@ant-design/icons
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import IndicatorLight from '@renderer/components/IndicatorLight'
|
||||
import { VStack } from '@renderer/components/Layout'
|
||||
import { Button, Flex } from 'antd'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
@ -14,6 +15,7 @@ import styled from 'styled-components'
|
||||
import { SettingContainer, SettingTitle } from '..'
|
||||
import InstallNpxUv from './InstallNpxUv'
|
||||
import McpSettings from './McpSettings'
|
||||
import McpToolCallingSettings from './McpToolCallingSettings'
|
||||
import NpxSearch from './NpxSearch'
|
||||
|
||||
const MCPSettings: FC = () => {
|
||||
@ -65,7 +67,14 @@ const MCPSettings: FC = () => {
|
||||
() => (
|
||||
<GridContainer>
|
||||
<GridHeader>
|
||||
<SettingTitle>{t('settings.mcp.newServer')}</SettingTitle>
|
||||
<SettingTitle>
|
||||
<Flex justify="space-between" align="center">
|
||||
{t('settings.mcp.newServer')}
|
||||
<Link to="/settings/mcp/tool-calling">
|
||||
<Button type="link">{t('settings.mcp.tool_calling.title', '工具调用设置')}</Button>
|
||||
</Link>
|
||||
</Flex>
|
||||
</SettingTitle>
|
||||
</GridHeader>
|
||||
<ServersGrid>
|
||||
<AddServerCard onClick={onAddMcpServer}>
|
||||
@ -122,6 +131,7 @@ const MCPSettings: FC = () => {
|
||||
<Routes>
|
||||
<Route path="/" element={<McpServersList />} />
|
||||
<Route path="server/:id" element={selectedMcpServer ? <McpSettings server={selectedMcpServer} /> : null} />
|
||||
<Route path="tool-calling" element={<McpToolCallingSettings />} />
|
||||
<Route
|
||||
path="npx-search"
|
||||
element={
|
||||
|
||||
@ -7,9 +7,15 @@ import { Button, Empty, Input, List, Select, Switch, Tooltip, Typography } from
|
||||
import _ from 'lodash'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
const StyledSelect = styled(Select<string>)`
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
`
|
||||
|
||||
const AssistantMemoryManager = () => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
@ -49,81 +55,83 @@ const AssistantMemoryManager = () => {
|
||||
}
|
||||
|
||||
// 添加新的助手记忆 - 使用防抖减少频繁更新
|
||||
const handleAddMemory = useCallback(
|
||||
_.debounce(() => {
|
||||
const handleAddMemory = useCallback(() => {
|
||||
const debouncedAdd = _.debounce(() => {
|
||||
if (newMemoryContent.trim() && selectedAssistantId) {
|
||||
addAssistantMemoryItem(newMemoryContent.trim(), selectedAssistantId)
|
||||
setNewMemoryContent('') // 清空输入框
|
||||
}
|
||||
}, 300),
|
||||
[newMemoryContent, selectedAssistantId]
|
||||
)
|
||||
}, 300)
|
||||
debouncedAdd()
|
||||
}, [newMemoryContent, selectedAssistantId])
|
||||
|
||||
// 删除助手记忆 - 直接删除无需确认,使用节流避免频繁删除操作
|
||||
const handleDeleteMemory = useCallback(
|
||||
_.throttle(async (id: string) => {
|
||||
// 先从当前状态中获取要删除的记忆之外的所有记忆
|
||||
const state = store.getState().memory
|
||||
const filteredAssistantMemories = state.assistantMemories.filter((memory) => memory.id !== id)
|
||||
(id: string) => {
|
||||
const throttledDelete = _.throttle(async (memoryId: string) => {
|
||||
// 先从当前状态中获取要删除的记忆之外的所有记忆
|
||||
const state = store.getState().memory
|
||||
const filteredAssistantMemories = state.assistantMemories.filter((memory) => memory.id !== memoryId)
|
||||
|
||||
// 执行删除操作
|
||||
dispatch(deleteAssistantMemory(id))
|
||||
// 执行删除操作
|
||||
dispatch(deleteAssistantMemory(memoryId))
|
||||
|
||||
// 直接使用 window.api.memory.saveData 方法保存过滤后的列表
|
||||
try {
|
||||
// 加载当前文件数据
|
||||
const currentData = await window.api.memory.loadData()
|
||||
// 直接使用 window.api.memory.saveData 方法保存过滤后的列表
|
||||
try {
|
||||
// 加载当前文件数据
|
||||
const currentData = await window.api.memory.loadData()
|
||||
|
||||
// 替换 assistantMemories 数组,保留其他重要设置
|
||||
const newData = {
|
||||
...currentData,
|
||||
assistantMemories: filteredAssistantMemories,
|
||||
assistantMemoryActive: currentData.assistantMemoryActive,
|
||||
assistantMemoryAnalyzeModel: currentData.assistantMemoryAnalyzeModel
|
||||
// 替换 assistantMemories 数组,保留其他重要设置
|
||||
const newData = {
|
||||
...currentData,
|
||||
assistantMemories: filteredAssistantMemories,
|
||||
assistantMemoryActive: currentData.assistantMemoryActive,
|
||||
assistantMemoryAnalyzeModel: currentData.assistantMemoryAnalyzeModel
|
||||
}
|
||||
|
||||
// 使用 true 参数强制覆盖文件
|
||||
const result = await window.api.memory.saveData(newData, true)
|
||||
|
||||
if (result) {
|
||||
console.log(`[AssistantMemoryManager] Successfully deleted assistant memory with ID ${memoryId}`)
|
||||
// 移除消息提示,避免触发界面重新渲染
|
||||
} else {
|
||||
console.error(`[AssistantMemoryManager] Failed to delete assistant memory with ID ${memoryId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AssistantMemoryManager] Failed to delete assistant memory:', error)
|
||||
}
|
||||
|
||||
// 使用 true 参数强制覆盖文件
|
||||
const result = await window.api.memory.saveData(newData, true)
|
||||
|
||||
if (result) {
|
||||
console.log(`[AssistantMemoryManager] Successfully deleted assistant memory with ID ${id}`)
|
||||
// 移除消息提示,避免触发界面重新渲染
|
||||
} else {
|
||||
console.error(`[AssistantMemoryManager] Failed to delete assistant memory with ID ${id}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AssistantMemoryManager] Failed to delete assistant memory:', error)
|
||||
}
|
||||
}, 500),
|
||||
}, 500)
|
||||
throttledDelete(id)
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="assistant-memory-manager">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<HeaderContainer>
|
||||
<Title level={4}>{t('settings.memory.assistantMemory') || '助手记忆'}</Title>
|
||||
<Tooltip title={t('settings.memory.toggleAssistantMemoryActive') || '切换助手记忆功能'}>
|
||||
<Switch checked={assistantMemoryActive} onChange={handleToggleActive} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</HeaderContainer>
|
||||
|
||||
{/* 助手选择器 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
<SectionContainer>
|
||||
<StyledSelect
|
||||
value={selectedAssistantId}
|
||||
onChange={setSelectedAssistantId}
|
||||
onChange={(value: string) => setSelectedAssistantId(value)}
|
||||
placeholder={t('settings.memory.selectAssistant') || '选择助手'}
|
||||
style={{ width: '100%', marginBottom: 16 }}
|
||||
disabled={!assistantMemoryActive}>
|
||||
{assistants.map((assistant) => (
|
||||
<Select.Option key={assistant.id} value={assistant.id}>
|
||||
{assistant.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</StyledSelect>
|
||||
</SectionContainer>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<SectionContainer>
|
||||
<Input.TextArea
|
||||
value={newMemoryContent}
|
||||
onChange={(e) => setNewMemoryContent(e.target.value)}
|
||||
@ -131,14 +139,13 @@ const AssistantMemoryManager = () => {
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
disabled={!assistantMemoryActive || !selectedAssistantId}
|
||||
/>
|
||||
<Button
|
||||
<AddButton
|
||||
type="primary"
|
||||
onClick={() => handleAddMemory()}
|
||||
style={{ marginTop: 8 }}
|
||||
disabled={!assistantMemoryActive || !newMemoryContent.trim() || !selectedAssistantId}>
|
||||
{t('settings.memory.addAssistantMemory') || '添加助手记忆'}
|
||||
</Button>
|
||||
</div>
|
||||
</AddButton>
|
||||
</SectionContainer>
|
||||
|
||||
<div className="assistant-memories-list">
|
||||
{assistantMemories.length > 0 ? (
|
||||
@ -158,7 +165,7 @@ const AssistantMemoryManager = () => {
|
||||
</Tooltip>
|
||||
]}>
|
||||
<List.Item.Meta
|
||||
title={<div style={{ wordBreak: 'break-word' }}>{memory.content}</div>}
|
||||
title={<MemoryContent>{memory.content}</MemoryContent>}
|
||||
description={new Date(memory.createdAt).toLocaleString()}
|
||||
/>
|
||||
</List.Item>
|
||||
@ -178,4 +185,23 @@ const AssistantMemoryManager = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
`
|
||||
|
||||
const SectionContainer = styled.div`
|
||||
margin-bottom: 16px;
|
||||
`
|
||||
|
||||
const AddButton = styled(Button)`
|
||||
margin-top: 8px;
|
||||
`
|
||||
|
||||
const MemoryContent = styled.div`
|
||||
word-break: break-word;
|
||||
`
|
||||
|
||||
export default AssistantMemoryManager
|
||||
|
||||
@ -6,6 +6,7 @@ import { Button, Empty, Input, List, Switch, Tooltip, Typography } from 'antd'
|
||||
import _ from 'lodash'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Title } = Typography
|
||||
// 不再需要确认对话框
|
||||
@ -69,14 +70,14 @@ const ShortMemoryManager = () => {
|
||||
|
||||
return (
|
||||
<div className="short-memory-manager">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<HeaderContainer>
|
||||
<Title level={4}>{t('settings.memory.shortMemory')}</Title>
|
||||
<Tooltip title={t('settings.memory.toggleShortMemoryActive')}>
|
||||
<Switch checked={shortMemoryActive} onChange={handleToggleActive} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</HeaderContainer>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<SectionContainer>
|
||||
<Input.TextArea
|
||||
value={newMemoryContent}
|
||||
onChange={(e) => setNewMemoryContent(e.target.value)}
|
||||
@ -84,14 +85,13 @@ const ShortMemoryManager = () => {
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
disabled={!shortMemoryActive || !currentTopicId}
|
||||
/>
|
||||
<Button
|
||||
<AddButton
|
||||
type="primary"
|
||||
onClick={() => handleAddMemory()}
|
||||
style={{ marginTop: 8 }}
|
||||
disabled={!shortMemoryActive || !newMemoryContent.trim() || !currentTopicId}>
|
||||
{t('settings.memory.addShortMemory')}
|
||||
</Button>
|
||||
</div>
|
||||
</AddButton>
|
||||
</SectionContainer>
|
||||
|
||||
<div className="short-memories-list">
|
||||
{shortMemories.length > 0 ? (
|
||||
@ -111,7 +111,7 @@ const ShortMemoryManager = () => {
|
||||
</Tooltip>
|
||||
]}>
|
||||
<List.Item.Meta
|
||||
title={<div style={{ wordBreak: 'break-word' }}>{memory.content}</div>}
|
||||
title={<MemoryContent>{memory.content}</MemoryContent>}
|
||||
description={new Date(memory.createdAt).toLocaleString()}
|
||||
/>
|
||||
</List.Item>
|
||||
@ -127,4 +127,23 @@ const ShortMemoryManager = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
`
|
||||
|
||||
const SectionContainer = styled.div`
|
||||
margin-bottom: 16px;
|
||||
`
|
||||
|
||||
const AddButton = styled(Button)`
|
||||
margin-top: 8px;
|
||||
`
|
||||
|
||||
const MemoryContent = styled.div`
|
||||
word-break: break-word;
|
||||
`
|
||||
|
||||
export default ShortMemoryManager
|
||||
|
||||
@ -592,8 +592,22 @@ export default class GeminiProvider extends BaseProvider {
|
||||
// 将当前工具调用添加到历史中
|
||||
const updatedToolResponses = [...previousToolResponses, currentToolResponse]
|
||||
|
||||
// 将工具调用和响应添加到历史中
|
||||
// 将工具调用添加到历史中(模型角色)
|
||||
const toolCallMessage: Content = {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: functionCall // 添加原始的函数调用
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 将工具调用添加到历史中
|
||||
history.push(toolCallMessage)
|
||||
|
||||
// 将工具调用响应添加到历史中(用户角色,但使用文本格式)
|
||||
// 注意:GoogleGenerativeAI API 有限制,role为'user'的内容不能包含'functionResponse'部分
|
||||
const toolResponseMessage: Content = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
@ -610,8 +624,12 @@ export default class GeminiProvider extends BaseProvider {
|
||||
]
|
||||
}
|
||||
|
||||
// 将工具调用结果添加到历史中
|
||||
history.push(toolCallMessage)
|
||||
// 将工具响应添加到历史中
|
||||
history.push(toolResponseMessage)
|
||||
|
||||
// 打印添加到历史记录的内容,便于调试
|
||||
console.log('[GeminiProvider] 添加到历史的工具调用:', JSON.stringify(toolCallMessage, null, 2))
|
||||
console.log('[GeminiProvider] 添加到历史的工具响应:', JSON.stringify(toolResponseMessage, null, 2))
|
||||
|
||||
// 打印历史记录信息,便于调试
|
||||
console.log(`[GeminiProvider] 工具调用历史记录已更新,当前历史长度: ${history.length}`)
|
||||
|
||||
@ -330,11 +330,21 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
}
|
||||
}
|
||||
if (mcpTools && mcpTools.length > 0) {
|
||||
systemMessage.content = await buildSystemPrompt(
|
||||
systemMessage.content || '',
|
||||
mcpTools,
|
||||
getActiveServers(store.getState())
|
||||
)
|
||||
// 获取是否使用提示词调用工具的设置
|
||||
const usePromptForToolCalling = store.getState().settings.usePromptForToolCalling
|
||||
|
||||
if (usePromptForToolCalling) {
|
||||
// 使用提示词调用工具
|
||||
systemMessage.content = await buildSystemPrompt(
|
||||
systemMessage.content || '',
|
||||
mcpTools,
|
||||
getActiveServers(store.getState())
|
||||
)
|
||||
console.log('[OpenAIProvider] 使用提示词调用MCP工具')
|
||||
} else {
|
||||
// 使用函数调用
|
||||
console.log('[OpenAIProvider] 使用函数调用MCP工具')
|
||||
}
|
||||
}
|
||||
|
||||
const userMessages: ChatCompletionMessageParam[] = []
|
||||
@ -413,25 +423,138 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
// 处理连续的相同角色消息,例如 deepseek-reasoner 模型不支持连续的用户或助手消息
|
||||
console.debug('[tool] reqMessages before processing', model.id, reqMessages)
|
||||
reqMessages = processReqMessages(model, reqMessages)
|
||||
console.debug('[tool] reqMessages', model.id, reqMessages)
|
||||
|
||||
const toolResponses: MCPToolResponse[] = []
|
||||
let firstChunk = true
|
||||
|
||||
const processToolUses = async (content: string, idx: number) => {
|
||||
// 只执行工具调用,不生成第二条消息
|
||||
await parseAndCallTools(
|
||||
content,
|
||||
toolResponses,
|
||||
onChunk,
|
||||
idx,
|
||||
mcpToolCallResponseToOpenAIMessage,
|
||||
mcpTools,
|
||||
isVisionModel(model)
|
||||
)
|
||||
try {
|
||||
// 执行工具调用并获取结果
|
||||
const toolResults = await parseAndCallTools(
|
||||
content,
|
||||
toolResponses,
|
||||
onChunk,
|
||||
idx,
|
||||
mcpToolCallResponseToOpenAIMessage,
|
||||
mcpTools,
|
||||
isVisionModel(model)
|
||||
)
|
||||
|
||||
// 不再生成基于工具结果的新消息
|
||||
console.log('[OpenAIProvider] 工具调用已执行,不生成第二条消息')
|
||||
// 如果有工具调用结果,将其添加到上下文中,并进行第二次API调用
|
||||
if (toolResults.length > 0) {
|
||||
console.log('[OpenAIProvider] 工具调用已执行,将结果添加到上下文并生成第二条消息')
|
||||
|
||||
// 添加原始助手消息到消息列表
|
||||
reqMessages.push({
|
||||
role: 'assistant',
|
||||
content: content
|
||||
} as ChatCompletionMessageParam)
|
||||
|
||||
// 添加工具调用结果到消息列表
|
||||
toolResults.forEach((ts) => reqMessages.push(ts as ChatCompletionMessageParam))
|
||||
|
||||
try {
|
||||
// 进行第二次API调用
|
||||
const requestParams: any = {
|
||||
model: model.id,
|
||||
messages: reqMessages,
|
||||
temperature: this.getTemperature(assistant, model),
|
||||
top_p: this.getTopP(assistant, model),
|
||||
max_tokens: maxTokens,
|
||||
stream: isSupportStreamOutput()
|
||||
}
|
||||
|
||||
// 添加其他参数
|
||||
const webSearchParams = getOpenAIWebSearchParams(assistant, model)
|
||||
const reasoningEffort = this.getReasoningEffort(assistant, model)
|
||||
const specificParams = this.getProviderSpecificParameters(assistant, model)
|
||||
const customParams = this.getCustomParameters(assistant)
|
||||
|
||||
// 合并所有参数
|
||||
const newStream = await this.sdk.chat.completions.create(
|
||||
{
|
||||
...requestParams,
|
||||
...webSearchParams,
|
||||
...reasoningEffort,
|
||||
...specificParams,
|
||||
...customParams
|
||||
},
|
||||
{
|
||||
signal
|
||||
}
|
||||
)
|
||||
|
||||
// 处理第二次响应
|
||||
await processStream(newStream, idx + 1)
|
||||
} catch (error: any) {
|
||||
// 处理API调用错误
|
||||
console.error('[OpenAIProvider] 第二次API调用失败:', error)
|
||||
|
||||
// 尝试使用简化的消息列表再次请求
|
||||
try {
|
||||
// 创建简化的消息列表,只包含系统消息、最后一条用户消息和工具调用结果
|
||||
const lastUserMessage = reqMessages.find((m) => m.role === 'user')
|
||||
const simplifiedMessages: ChatCompletionMessageParam[] = [
|
||||
reqMessages[0], // 系统消息
|
||||
...(lastUserMessage ? [lastUserMessage] : []), // 最后一条用户消息,如果存在
|
||||
...toolResults.map((ts) => ts as ChatCompletionMessageParam) // 工具调用结果
|
||||
]
|
||||
|
||||
// 等待3秒再尝试
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
const fallbackResponse = await this.sdk.chat.completions.create(
|
||||
{
|
||||
model: model.id,
|
||||
messages: simplifiedMessages,
|
||||
temperature: this.getTemperature(assistant, model),
|
||||
top_p: this.getTopP(assistant, model),
|
||||
max_tokens: maxTokens,
|
||||
stream: isSupportStreamOutput()
|
||||
},
|
||||
{
|
||||
signal
|
||||
}
|
||||
)
|
||||
|
||||
// 处理备用方案响应
|
||||
if (isSupportStreamOutput()) {
|
||||
await processStream(fallbackResponse, idx + 1)
|
||||
} else {
|
||||
// 非流式响应的处理
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
const response = fallbackResponse as OpenAI.Chat.Completions.ChatCompletion
|
||||
|
||||
onChunk({
|
||||
text: response.choices[0].message?.content || '',
|
||||
usage: response.usage,
|
||||
metrics: {
|
||||
completion_tokens: response.usage?.completion_tokens,
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (fallbackError: any) {
|
||||
// 备用方案也失败
|
||||
onChunk({
|
||||
text: `\n\n工具调用结果处理失败: ${error.message || '未知错误'}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 处理工具调用过程中的错误
|
||||
console.error('[OpenAIProvider] 工具调用过程出错:', error)
|
||||
|
||||
// 向用户发送错误消息
|
||||
onChunk({
|
||||
text: `\n\n工具调用过程出错: ${error.message || '未知错误'}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const processStream = async (stream: any, idx: number) => {
|
||||
@ -599,7 +722,18 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
max_tokens: maxTokens,
|
||||
keep_alive: this.keepAliveTime,
|
||||
stream: isSupportStreamOutput(),
|
||||
// tools: tools,
|
||||
...(mcpTools && mcpTools.length > 0 && !store.getState().settings.usePromptForToolCalling
|
||||
? {
|
||||
tools: mcpTools.map((tool) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema
|
||||
}
|
||||
}))
|
||||
}
|
||||
: {}),
|
||||
...getOpenAIWebSearchParams(assistant, model),
|
||||
...this.getReasoningEffort(assistant, model),
|
||||
...this.getProviderSpecificParameters(assistant, model),
|
||||
|
||||
@ -0,0 +1,867 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { WebSearchState } from '@renderer/store/websearch'
|
||||
import { WebSearchProvider, WebSearchResponse, WebSearchResult } from '@renderer/types'
|
||||
import { fetchWebContent, noContent } from '@renderer/utils/fetch'
|
||||
|
||||
// 定义分析结果类型
|
||||
interface AnalyzedResult extends WebSearchResult {
|
||||
summary?: string // 内容摘要
|
||||
keywords?: string[] // 关键词
|
||||
relevanceScore?: number // 相关性评分
|
||||
}
|
||||
|
||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||
|
||||
export default class DeepSearchProvider extends BaseWebSearchProvider {
|
||||
// 定义默认的搜索引擎URLs
|
||||
private searchEngines = [
|
||||
{ name: 'Baidu', url: 'https://www.baidu.com/s?wd=%s' },
|
||||
{ name: 'Bing', url: 'https://cn.bing.com/search?q=%s&ensearch=1' },
|
||||
{ name: 'DuckDuckGo', url: 'https://duckduckgo.com/?q=%s&t=h_' },
|
||||
{ name: 'Sogou', url: 'https://www.sogou.com/web?query=%s' },
|
||||
{
|
||||
name: 'SearX',
|
||||
url: 'https://searx.tiekoetter.com/search?q=%s&categories=general&language=auto&time_range=&safesearch=0&theme=simple'
|
||||
}
|
||||
]
|
||||
|
||||
// 分析模型配置
|
||||
private analyzeConfig = {
|
||||
enabled: true, // 是否启用预分析
|
||||
maxSummaryLength: 300, // 每个结果的摘要最大长度
|
||||
batchSize: 3 // 每批分析的结果数量
|
||||
}
|
||||
|
||||
constructor(provider: WebSearchProvider) {
|
||||
super(provider)
|
||||
// 不再强制要求provider.url,因为我们有默认的搜索引擎
|
||||
}
|
||||
|
||||
public async search(query: string, websearch: WebSearchState): Promise<WebSearchResponse> {
|
||||
try {
|
||||
if (!query.trim()) {
|
||||
throw new Error('Search query cannot be empty')
|
||||
}
|
||||
|
||||
const cleanedQuery = query.split('\r\n')[1] ?? query
|
||||
console.log(`[DeepSearch] 开始多引擎并行搜索: ${cleanedQuery}`)
|
||||
|
||||
// 存储所有搜索引擎的结果
|
||||
const allItems: Array<{ title: string; url: string; source: string }> = []
|
||||
|
||||
// 并行搜索所有引擎
|
||||
const searchPromises = this.searchEngines.map(async (engine) => {
|
||||
try {
|
||||
const uid = `deep-search-${engine.name.toLowerCase()}-${nanoid()}`
|
||||
const url = engine.url.replace('%s', encodeURIComponent(cleanedQuery))
|
||||
|
||||
console.log(`[DeepSearch] 使用${engine.name}搜索: ${url}`)
|
||||
|
||||
// 使用搜索窗口获取搜索结果页面内容
|
||||
const content = await window.api.searchService.openUrlInSearchWindow(uid, url)
|
||||
|
||||
// 解析搜索结果页面中的URL
|
||||
const searchItems = this.parseValidUrls(content)
|
||||
console.log(`[DeepSearch] ${engine.name}找到 ${searchItems.length} 个结果`)
|
||||
|
||||
// 添加搜索引擎标记
|
||||
return searchItems.map((item) => ({
|
||||
...item,
|
||||
source: engine.name
|
||||
}))
|
||||
} catch (engineError) {
|
||||
console.error(`[DeepSearch] ${engine.name}搜索失败:`, engineError)
|
||||
// 如果失败返回空数组
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// 如果用户在provider中指定了URL,也并行搜索
|
||||
if (this.provider.url) {
|
||||
searchPromises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const uid = `deep-search-custom-${nanoid()}`
|
||||
const url = this.provider.url ? this.provider.url.replace('%s', encodeURIComponent(cleanedQuery)) : ''
|
||||
|
||||
console.log(`[DeepSearch] 使用自定义搜索: ${url}`)
|
||||
|
||||
// 使用搜索窗口获取搜索结果页面内容
|
||||
const content = await window.api.searchService.openUrlInSearchWindow(uid, url)
|
||||
|
||||
// 解析搜索结果页面中的URL
|
||||
const searchItems = this.parseValidUrls(content)
|
||||
console.log(`[DeepSearch] 自定义搜索找到 ${searchItems.length} 个结果`)
|
||||
|
||||
// 添加搜索引擎标记
|
||||
return searchItems.map((item) => ({
|
||||
...item,
|
||||
source: '自定义'
|
||||
}))
|
||||
} catch (customError) {
|
||||
console.error('[DeepSearch] 自定义搜索失败:', customError)
|
||||
return []
|
||||
}
|
||||
})()
|
||||
)
|
||||
}
|
||||
|
||||
// 等待所有搜索完成
|
||||
const searchResults = await Promise.all(searchPromises)
|
||||
|
||||
// 合并所有搜索结果
|
||||
for (const results of searchResults) {
|
||||
allItems.push(...results)
|
||||
}
|
||||
|
||||
console.log(`[DeepSearch] 总共找到 ${allItems.length} 个结果`)
|
||||
|
||||
// 去重,使用URL作为唯一标识
|
||||
const uniqueUrls = new Set<string>()
|
||||
const uniqueItems = allItems.filter((item) => {
|
||||
if (uniqueUrls.has(item.url)) {
|
||||
return false
|
||||
}
|
||||
uniqueUrls.add(item.url)
|
||||
return true
|
||||
})
|
||||
|
||||
console.log(`[DeepSearch] 去重后有 ${uniqueItems.length} 个结果`)
|
||||
|
||||
// 过滤有效的URL,不限制数量
|
||||
const validItems = uniqueItems.filter((item) => item.url.startsWith('http') || item.url.startsWith('https'))
|
||||
|
||||
console.log(`[DeepSearch] 过滤后有 ${validItems.length} 个有效结果`)
|
||||
|
||||
// 第二步:抓取每个URL的内容
|
||||
const results = await this.fetchContentsWithDepth(validItems, websearch)
|
||||
|
||||
// 如果启用了预分析,对结果进行分析
|
||||
let analyzedResults = results
|
||||
if (this.analyzeConfig.enabled) {
|
||||
analyzedResults = await this.analyzeResults(results, cleanedQuery)
|
||||
}
|
||||
|
||||
// 在标题中添加搜索引擎来源和摘要
|
||||
const resultsWithSource = analyzedResults.map((result, index) => {
|
||||
if (index < validItems.length) {
|
||||
// 如果有摘要,在内容前面添加摘要
|
||||
let enhancedContent = result.content
|
||||
const summary = (result as AnalyzedResult).summary
|
||||
|
||||
if (summary && summary !== enhancedContent.substring(0, summary.length)) {
|
||||
enhancedContent = `**摘要**: ${summary}\n\n---\n\n${enhancedContent}`
|
||||
}
|
||||
|
||||
// 如果有关键词,在内容前面添加关键词
|
||||
const keywords = (result as AnalyzedResult).keywords
|
||||
if (keywords && keywords.length > 0) {
|
||||
enhancedContent = `**关键词**: ${keywords.join(', ')}\n\n${enhancedContent}`
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
title: `[${validItems[index].source}] ${result.title}`,
|
||||
content: enhancedContent
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// 按相关性排序
|
||||
const sortedResults = [...resultsWithSource].sort((a, b) => {
|
||||
const scoreA = (a as AnalyzedResult).relevanceScore || 0
|
||||
const scoreB = (b as AnalyzedResult).relevanceScore || 0
|
||||
return scoreB - scoreA
|
||||
})
|
||||
|
||||
return {
|
||||
query: query,
|
||||
results: sortedResults.filter((result) => result.content !== noContent)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DeepSearch] 搜索失败:', error)
|
||||
throw new Error(`DeepSearch failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析搜索结果,提取摘要和关键词
|
||||
* @param results 搜索结果
|
||||
* @param query 搜索查询
|
||||
* @returns 分析后的结果
|
||||
*/
|
||||
private async analyzeResults(results: WebSearchResult[], query: string): Promise<AnalyzedResult[]> {
|
||||
console.log(`[DeepSearch] 开始分析 ${results.length} 个结果`)
|
||||
|
||||
// 分批处理,避免处理过多内容
|
||||
const batchSize = this.analyzeConfig.batchSize
|
||||
const analyzedResults: AnalyzedResult[] = [...results] // 复制原始结果
|
||||
|
||||
// 简单的分析逻辑:提取前几句作为摘要
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i]
|
||||
if (result.content === noContent) continue
|
||||
|
||||
try {
|
||||
// 提取摘要(简单实现,取前300个字符)
|
||||
const maxLength = this.analyzeConfig.maxSummaryLength
|
||||
let summary = result.content.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
|
||||
if (summary.length > maxLength) {
|
||||
// 截取到最后一个完整的句子
|
||||
summary = summary.substring(0, maxLength)
|
||||
const lastPeriod = summary.lastIndexOf('.')
|
||||
if (lastPeriod > maxLength * 0.7) {
|
||||
// 至少要有总长度的70%
|
||||
summary = summary.substring(0, lastPeriod + 1)
|
||||
}
|
||||
summary += '...'
|
||||
}
|
||||
|
||||
// 提取关键词(简单实现,基于查询词拆分)
|
||||
const keywords = query
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 2 && result.content.toLowerCase().includes(word.toLowerCase()))
|
||||
|
||||
// 计算相关性评分(简单实现,基于关键词出现频率)
|
||||
let relevanceScore = 0
|
||||
if (keywords.length > 0) {
|
||||
const contentLower = result.content.toLowerCase()
|
||||
for (const word of keywords) {
|
||||
const wordLower = word.toLowerCase()
|
||||
// 计算关键词出现的次数
|
||||
let count = 0
|
||||
let pos = contentLower.indexOf(wordLower)
|
||||
while (pos !== -1) {
|
||||
count++
|
||||
pos = contentLower.indexOf(wordLower, pos + 1)
|
||||
}
|
||||
relevanceScore += count
|
||||
}
|
||||
// 标准化评分,范围为0-1
|
||||
relevanceScore = Math.min(1, relevanceScore / (contentLower.length / 100))
|
||||
}
|
||||
|
||||
// 更新分析结果
|
||||
analyzedResults[i] = {
|
||||
...analyzedResults[i],
|
||||
summary,
|
||||
keywords,
|
||||
relevanceScore
|
||||
}
|
||||
|
||||
// 每处理一批打印一次日志
|
||||
if (i % batchSize === 0 || i === results.length - 1) {
|
||||
console.log(`[DeepSearch] 已分析 ${i + 1}/${results.length} 个结果`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[DeepSearch] 分析结果 ${i} 失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 按相关性排序
|
||||
analyzedResults.sort((a, b) => {
|
||||
const scoreA = (a as AnalyzedResult).relevanceScore || 0
|
||||
const scoreB = (b as AnalyzedResult).relevanceScore || 0
|
||||
return scoreB - scoreA
|
||||
})
|
||||
|
||||
console.log(`[DeepSearch] 完成分析 ${results.length} 个结果`)
|
||||
return analyzedResults
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析搜索结果页面中的URL
|
||||
* 默认实现,子类可以覆盖此方法以适应不同的搜索引擎
|
||||
*/
|
||||
protected parseValidUrls(htmlContent: string): Array<{ title: string; url: string }> {
|
||||
const results: Array<{ title: string; url: string }> = []
|
||||
|
||||
try {
|
||||
// 通用解析逻辑,查找所有链接
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(htmlContent, 'text/html')
|
||||
|
||||
// 尝试解析Baidu搜索结果 - 使用多个选择器来获取更多结果
|
||||
const baiduResults = [
|
||||
...doc.querySelectorAll('#content_left .result h3 a'),
|
||||
...doc.querySelectorAll('#content_left .c-container h3 a'),
|
||||
...doc.querySelectorAll('#content_left .c-container a.c-title'),
|
||||
...doc.querySelectorAll('#content_left a[data-click]')
|
||||
]
|
||||
|
||||
// 尝试解析Bing搜索结果 - 使用多个选择器来获取更多结果
|
||||
const bingResults = [
|
||||
...doc.querySelectorAll('.b_algo h2 a'),
|
||||
...doc.querySelectorAll('.b_algo a.tilk'),
|
||||
...doc.querySelectorAll('.b_algo a.b_title'),
|
||||
...doc.querySelectorAll('.b_results a.b_restorLink')
|
||||
]
|
||||
|
||||
// 尝试解析DuckDuckGo搜索结果 - 使用多个选择器来获取更多结果
|
||||
// 注意:DuckDuckGo的DOM结构可能会变化,所以我们使用多种选择器
|
||||
const duckduckgoResults = [
|
||||
// 标准结果选择器
|
||||
...doc.querySelectorAll('.result__a'), // 主要结果链接
|
||||
...doc.querySelectorAll('.result__url'), // URL链接
|
||||
...doc.querySelectorAll('.result__snippet a'), // 片段中的链接
|
||||
...doc.querySelectorAll('.results_links_deep a'), // 深度链接
|
||||
|
||||
// 新的选择器,适应可能的DOM变化
|
||||
...doc.querySelectorAll('a.result__check'), // 可能的新结果链接
|
||||
...doc.querySelectorAll('a.js-result-title-link'), // 可能的标题链接
|
||||
...doc.querySelectorAll('article a'), // 文章中的链接
|
||||
...doc.querySelectorAll('.nrn-react-div a'), // React渲染的链接
|
||||
|
||||
// 通用选择器,捕获更多可能的结果
|
||||
...doc.querySelectorAll('a[href*="http"]'), // 所有外部链接
|
||||
...doc.querySelectorAll('a[data-testid]'), // 所有测试ID链接
|
||||
...doc.querySelectorAll('.module a') // 模块中的链接
|
||||
]
|
||||
|
||||
// 尝试解析搜狗搜索结果 - 使用多个选择器来获取更多结果
|
||||
const sogouResults = [
|
||||
// 标准结果选择器
|
||||
...doc.querySelectorAll('.vrwrap h3 a'), // 主要结果链接
|
||||
...doc.querySelectorAll('.vr-title a'), // 标题链接
|
||||
...doc.querySelectorAll('.citeurl a'), // 引用URL链接
|
||||
...doc.querySelectorAll('.fz-mid a'), // 中间大小的链接
|
||||
...doc.querySelectorAll('.vrTitle a'), // 另一种标题链接
|
||||
...doc.querySelectorAll('.fb a'), // 可能的链接
|
||||
...doc.querySelectorAll('.results a'), // 结果链接
|
||||
|
||||
// 更多选择器,适应可能的DOM变化
|
||||
...doc.querySelectorAll('.rb a'), // 右侧栏链接
|
||||
...doc.querySelectorAll('.vr_list a'), // 列表链接
|
||||
...doc.querySelectorAll('.vrResult a'), // 结果链接
|
||||
...doc.querySelectorAll('.vr_tit_a'), // 标题链接
|
||||
...doc.querySelectorAll('.vr_title a') // 另一种标题链接
|
||||
]
|
||||
|
||||
// 尝试解析SearX搜索结果 - 使用多个选择器来获取更多结果
|
||||
const searxResults = [
|
||||
// 标准结果选择器
|
||||
...doc.querySelectorAll('.result h4 a'), // 主要结果链接
|
||||
...doc.querySelectorAll('.result-content a'), // 结果内容中的链接
|
||||
...doc.querySelectorAll('.result-url'), // URL链接
|
||||
...doc.querySelectorAll('.result-header a'), // 结果头部链接
|
||||
...doc.querySelectorAll('.result-link'), // 结果链接
|
||||
...doc.querySelectorAll('.result a'), // 所有结果中的链接
|
||||
|
||||
// 更多选择器,适应可能的DOM变化
|
||||
...doc.querySelectorAll('.results a'), // 结果列表中的链接
|
||||
...doc.querySelectorAll('article a'), // 文章中的链接
|
||||
...doc.querySelectorAll('.url_wrapper a'), // URL包装器中的链接
|
||||
...doc.querySelectorAll('.external-link') // 外部链接
|
||||
]
|
||||
|
||||
if (baiduResults.length > 0) {
|
||||
// 这是Baidu搜索结果页面
|
||||
console.log('[DeepSearch] 检测到Baidu搜索结果页面')
|
||||
|
||||
// 使用Set去重
|
||||
const uniqueUrls = new Set<string>()
|
||||
|
||||
baiduResults.forEach((link) => {
|
||||
try {
|
||||
const url = (link as HTMLAnchorElement).href
|
||||
const title = link.textContent || url
|
||||
|
||||
// 过滤掉搜索引擎内部链接和重复链接
|
||||
if (
|
||||
url &&
|
||||
(url.startsWith('http') || url.startsWith('https')) &&
|
||||
!url.includes('google.com/search') &&
|
||||
!url.includes('bing.com/search') &&
|
||||
!url.includes('baidu.com/s?') &&
|
||||
!uniqueUrls.has(url)
|
||||
) {
|
||||
uniqueUrls.add(url)
|
||||
results.push({
|
||||
title: title.trim() || url,
|
||||
url: url
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略无效链接
|
||||
}
|
||||
})
|
||||
} else if (bingResults.length > 0) {
|
||||
// 这是Bing搜索结果页面
|
||||
console.log('[DeepSearch] 检测到Bing搜索结果页面')
|
||||
|
||||
// 使用Set去重
|
||||
const uniqueUrls = new Set<string>()
|
||||
|
||||
bingResults.forEach((link) => {
|
||||
try {
|
||||
const url = (link as HTMLAnchorElement).href
|
||||
const title = link.textContent || url
|
||||
|
||||
// 过滤掉搜索引擎内部链接和重复链接
|
||||
if (
|
||||
url &&
|
||||
(url.startsWith('http') || url.startsWith('https')) &&
|
||||
!url.includes('google.com/search') &&
|
||||
!url.includes('bing.com/search') &&
|
||||
!url.includes('baidu.com/s?') &&
|
||||
!uniqueUrls.has(url)
|
||||
) {
|
||||
uniqueUrls.add(url)
|
||||
results.push({
|
||||
title: title.trim() || url,
|
||||
url: url
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略无效链接
|
||||
}
|
||||
})
|
||||
} else if (sogouResults.length > 0 || htmlContent.includes('sogou.com')) {
|
||||
// 这是搜狗搜索结果页面
|
||||
console.log('[DeepSearch] 检测到搜狗搜索结果页面')
|
||||
|
||||
// 使用Set去重
|
||||
const uniqueUrls = new Set<string>()
|
||||
|
||||
sogouResults.forEach((link) => {
|
||||
try {
|
||||
const url = (link as HTMLAnchorElement).href
|
||||
const title = link.textContent || url
|
||||
|
||||
// 过滤掉搜索引擎内部链接和重复链接
|
||||
if (
|
||||
url &&
|
||||
(url.startsWith('http') || url.startsWith('https')) &&
|
||||
!url.includes('google.com/search') &&
|
||||
!url.includes('bing.com/search') &&
|
||||
!url.includes('baidu.com/s?') &&
|
||||
!url.includes('sogou.com/web') &&
|
||||
!url.includes('duckduckgo.com/?q=') &&
|
||||
!uniqueUrls.has(url)
|
||||
) {
|
||||
uniqueUrls.add(url)
|
||||
results.push({
|
||||
title: title.trim() || url,
|
||||
url: url
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略无效链接
|
||||
}
|
||||
})
|
||||
|
||||
// 如果结果很少,尝试使用更通用的方法
|
||||
if (results.length < 10) {
|
||||
// 增加阈值
|
||||
console.log('[DeepSearch] 搜狗标准选择器找到的结果很少,尝试使用更通用的方法')
|
||||
|
||||
// 获取所有链接
|
||||
const allLinks = doc.querySelectorAll('a')
|
||||
|
||||
allLinks.forEach((link) => {
|
||||
try {
|
||||
const url = (link as HTMLAnchorElement).href
|
||||
const title = link.textContent || url
|
||||
|
||||
// 更宽松的过滤条件
|
||||
if (
|
||||
url &&
|
||||
(url.startsWith('http') || url.startsWith('https')) &&
|
||||
!url.includes('sogou.com/web') &&
|
||||
!url.includes('javascript:') &&
|
||||
!url.includes('mailto:') &&
|
||||
!url.includes('tel:') &&
|
||||
!uniqueUrls.has(url) &&
|
||||
title.trim().length > 0
|
||||
) {
|
||||
uniqueUrls.add(url)
|
||||
results.push({
|
||||
title: title.trim() || url,
|
||||
url: url
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略无效链接
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[DeepSearch] 搜狗找到 ${results.length} 个结果`)
|
||||
} else if (searxResults.length > 0 || htmlContent.includes('searx.tiekoetter.com')) {
|
||||
// 这是SearX搜索结果页面
|
||||
console.log('[DeepSearch] 检测到SearX搜索结果页面')
|
||||
|
||||
// 使用Set去重
|
||||
const uniqueUrls = new Set<string>()
|
||||
|
||||
searxResults.forEach((link) => {
|
||||
try {
|
||||
const url = (link as HTMLAnchorElement).href
|
||||
const title = link.textContent || url
|
||||
|
||||
// 过滤掉搜索引擎内部链接和重复链接
|
||||
if (
|
||||
url &&
|
||||
(url.startsWith('http') || url.startsWith('https')) &&
|
||||
!url.includes('google.com/search') &&
|
||||
!url.includes('bing.com/search') &&
|
||||
!url.includes('baidu.com/s?') &&
|
||||
!url.includes('sogou.com/web') &&
|
||||
!url.includes('duckduckgo.com/?q=') &&
|
||||
!url.includes('searx.tiekoetter.com/search') &&
|
||||
!uniqueUrls.has(url)
|
||||
) {
|
||||
uniqueUrls.add(url)
|
||||
results.push({
|
||||
title: title.trim() || url,
|
||||
url: url
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略无效链接
|
||||
}
|
||||
})
|
||||
|
||||
// 如果结果很少,尝试使用更通用的方法
|
||||
if (results.length < 10) {
|
||||
console.log('[DeepSearch] SearX标准选择器找到的结果很少,尝试使用更通用的方法')
|
||||
|
||||
// 获取所有链接
|
||||
const allLinks = doc.querySelectorAll('a')
|
||||
|
||||
allLinks.forEach((link) => {
|
||||
try {
|
||||
const url = (link as HTMLAnchorElement).href
|
||||
const title = link.textContent || url
|
||||
|
||||
// 更宽松的过滤条件
|
||||
if (
|
||||
url &&
|
||||
(url.startsWith('http') || url.startsWith('https')) &&
|
||||
!url.includes('searx.tiekoetter.com/search') &&
|
||||
!url.includes('javascript:') &&
|
||||
!url.includes('mailto:') &&
|
||||
!url.includes('tel:') &&
|
||||
!uniqueUrls.has(url) &&
|
||||
title.trim().length > 0
|
||||
) {
|
||||
uniqueUrls.add(url)
|
||||
results.push({
|
||||
title: title.trim() || url,
|
||||
url: url
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略无效链接
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[DeepSearch] SearX找到 ${results.length} 个结果`)
|
||||
} else if (duckduckgoResults.length > 0 || htmlContent.includes('duckduckgo.com')) {
|
||||
// 这是DuckDuckGo搜索结果页面
|
||||
console.log('[DeepSearch] 检测到DuckDuckGo搜索结果页面')
|
||||
|
||||
// 使用Set去重
|
||||
const uniqueUrls = new Set<string>()
|
||||
|
||||
// 如果标准选择器没有找到结果,尝试使用更通用的方法
|
||||
if (duckduckgoResults.length < 10) {
|
||||
// 增加阈值
|
||||
console.log('[DeepSearch] DuckDuckGo标准选择器找到的结果很少,尝试使用更通用的方法')
|
||||
|
||||
// 获取所有链接
|
||||
const allLinks = doc.querySelectorAll('a')
|
||||
|
||||
allLinks.forEach((link) => {
|
||||
try {
|
||||
const url = (link as HTMLAnchorElement).href
|
||||
const title = link.textContent || url
|
||||
|
||||
// 更宽松的过滤条件,为DuckDuckGo特别定制
|
||||
if (
|
||||
url &&
|
||||
(url.startsWith('http') || url.startsWith('https')) &&
|
||||
!url.includes('duckduckgo.com') &&
|
||||
!url.includes('google.com/search') &&
|
||||
!url.includes('bing.com/search') &&
|
||||
!url.includes('baidu.com/s?') &&
|
||||
!url.includes('javascript:') &&
|
||||
!url.includes('mailto:') &&
|
||||
!url.includes('tel:') &&
|
||||
!url.includes('about:') &&
|
||||
!url.includes('chrome:') &&
|
||||
!url.includes('file:') &&
|
||||
!url.includes('login') &&
|
||||
!url.includes('signup') &&
|
||||
!url.includes('account') &&
|
||||
!uniqueUrls.has(url) &&
|
||||
title.trim().length > 0
|
||||
) {
|
||||
uniqueUrls.add(url)
|
||||
results.push({
|
||||
title: title.trim() || url,
|
||||
url: url
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略无效链接
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 使用标准选择器找到的结果
|
||||
duckduckgoResults.forEach((link) => {
|
||||
try {
|
||||
const url = (link as HTMLAnchorElement).href
|
||||
const title = link.textContent || url
|
||||
|
||||
// 过滤掉搜索引擎内部链接和重复链接
|
||||
if (
|
||||
url &&
|
||||
(url.startsWith('http') || url.startsWith('https')) &&
|
||||
!url.includes('google.com/search') &&
|
||||
!url.includes('bing.com/search') &&
|
||||
!url.includes('baidu.com/s?') &&
|
||||
!url.includes('duckduckgo.com/?q=') &&
|
||||
!uniqueUrls.has(url)
|
||||
) {
|
||||
uniqueUrls.add(url)
|
||||
results.push({
|
||||
title: title.trim() || url,
|
||||
url: url
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略无效链接
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 如果结果仍然很少,尝试使用更激进的方法
|
||||
if (results.length < 10 && htmlContent.includes('duckduckgo.com')) {
|
||||
// 增加阈值
|
||||
console.log('[DeepSearch] DuckDuckGo结果仍然很少,尝试提取所有可能的URL')
|
||||
|
||||
// 从整个HTML中提取URL
|
||||
const urlRegex = /https?:\/\/[^\s"'<>()]+/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = urlRegex.exec(htmlContent)) !== null) {
|
||||
const url = match[0]
|
||||
|
||||
// 过滤掉搜索引擎内部URL和重复链接
|
||||
if (
|
||||
!url.includes('duckduckgo.com') &&
|
||||
!url.includes('google.com/search') &&
|
||||
!url.includes('bing.com/search') &&
|
||||
!url.includes('baidu.com/s?') &&
|
||||
!url.includes('sogou.com/web') &&
|
||||
!url.includes('searx.tiekoetter.com/search') &&
|
||||
!uniqueUrls.has(url)
|
||||
) {
|
||||
uniqueUrls.add(url)
|
||||
results.push({
|
||||
title: url,
|
||||
url: url
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[DeepSearch] DuckDuckGo找到 ${results.length} 个结果`)
|
||||
} else {
|
||||
// 如果不能识别搜索引擎,尝试通用解析
|
||||
console.log('[DeepSearch] 使用通用解析方法')
|
||||
|
||||
// 查找所有链接
|
||||
const links = doc.querySelectorAll('a')
|
||||
const uniqueUrls = new Set<string>()
|
||||
|
||||
links.forEach((link) => {
|
||||
try {
|
||||
const url = (link as HTMLAnchorElement).href
|
||||
const title = link.textContent || url
|
||||
|
||||
// 过滤掉无效链接和搜索引擎内部链接
|
||||
if (
|
||||
url &&
|
||||
(url.startsWith('http') || url.startsWith('https')) &&
|
||||
!url.includes('google.com/search') &&
|
||||
!url.includes('bing.com/search') &&
|
||||
!url.includes('baidu.com/s?') &&
|
||||
!url.includes('duckduckgo.com/?q=') &&
|
||||
!url.includes('sogou.com/web') &&
|
||||
!url.includes('searx.tiekoetter.com/search') &&
|
||||
!uniqueUrls.has(url) &&
|
||||
// 过滤掉常见的无用链接
|
||||
!url.includes('javascript:') &&
|
||||
!url.includes('mailto:') &&
|
||||
!url.includes('tel:') &&
|
||||
!url.includes('login') &&
|
||||
!url.includes('register') &&
|
||||
!url.includes('signup') &&
|
||||
!url.includes('signin') &&
|
||||
title.trim().length > 0
|
||||
) {
|
||||
uniqueUrls.add(url)
|
||||
results.push({
|
||||
title: title.trim(),
|
||||
url: url
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略无效链接
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[DeepSearch] 解析到 ${results.length} 个有效链接`)
|
||||
} catch (error) {
|
||||
console.error('[DeepSearch] 解析HTML失败:', error)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度抓取内容
|
||||
* 不仅抓取搜索结果页面,还会抓取页面中的链接
|
||||
*/
|
||||
private async fetchContentsWithDepth(
|
||||
items: Array<{ title: string; url: string; source?: string }>,
|
||||
_websearch: WebSearchState,
|
||||
depth: number = 1
|
||||
): Promise<WebSearchResult[]> {
|
||||
console.log(`[DeepSearch] 开始并行深度抓取,深度: ${depth}`)
|
||||
|
||||
// 第一层:并行抓取初始URL的内容
|
||||
const firstLevelResults = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
console.log(`[DeepSearch] 抓取页面: ${item.url}`)
|
||||
try {
|
||||
const result = await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser)
|
||||
|
||||
// 应用内容长度限制
|
||||
if (
|
||||
this.provider.contentLimit &&
|
||||
this.provider.contentLimit !== -1 &&
|
||||
result.content.length > this.provider.contentLimit
|
||||
) {
|
||||
result.content = result.content.slice(0, this.provider.contentLimit) + '...'
|
||||
}
|
||||
|
||||
// 添加来源信息
|
||||
if (item.source) {
|
||||
result.source = item.source
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(`[DeepSearch] 抓取 ${item.url} 失败:`, error)
|
||||
return {
|
||||
title: item.title,
|
||||
content: noContent,
|
||||
url: item.url,
|
||||
source: item.source
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 如果深度为1,直接返回第一层结果
|
||||
if (depth <= 1) {
|
||||
return firstLevelResults
|
||||
}
|
||||
|
||||
// 第二层:从第一层内容中提取链接并抓取
|
||||
const secondLevelUrls: Set<string> = new Set()
|
||||
|
||||
// 从第一层结果中提取链接
|
||||
firstLevelResults.forEach((result) => {
|
||||
if (result.content !== noContent) {
|
||||
// 从Markdown内容中提取URL
|
||||
const urls = this.extractUrlsFromMarkdown(result.content)
|
||||
urls.forEach((url) => secondLevelUrls.add(url))
|
||||
}
|
||||
})
|
||||
|
||||
// 不限制第二层URL数量,获取更多结果
|
||||
const maxSecondLevelUrls = Math.min(secondLevelUrls.size, 30) // 增加到30个
|
||||
const secondLevelUrlsArray = Array.from(secondLevelUrls).slice(0, maxSecondLevelUrls)
|
||||
|
||||
console.log(`[DeepSearch] 第二层找到 ${secondLevelUrls.size} 个URL,将抓取 ${secondLevelUrlsArray.length} 个`)
|
||||
|
||||
// 抓取第二层URL的内容
|
||||
const secondLevelItems = secondLevelUrlsArray.map((url) => ({
|
||||
title: url,
|
||||
url: url,
|
||||
source: '深度链接' // 标记为深度链接
|
||||
}))
|
||||
|
||||
const secondLevelResults = await Promise.all(
|
||||
secondLevelItems.map(async (item) => {
|
||||
console.log(`[DeepSearch] 抓取第二层页面: ${item.url}`)
|
||||
try {
|
||||
const result = await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser)
|
||||
|
||||
// 应用内容长度限制
|
||||
if (
|
||||
this.provider.contentLimit &&
|
||||
this.provider.contentLimit !== -1 &&
|
||||
result.content.length > this.provider.contentLimit
|
||||
) {
|
||||
result.content = result.content.slice(0, this.provider.contentLimit) + '...'
|
||||
}
|
||||
|
||||
// 标记为第二层结果
|
||||
result.title = `[深度] ${result.title}`
|
||||
result.source = item.source
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(`[DeepSearch] 抓取第二层 ${item.url} 失败:`, error)
|
||||
return {
|
||||
title: `[深度] ${item.title}`,
|
||||
content: noContent,
|
||||
url: item.url,
|
||||
source: item.source
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 合并两层结果
|
||||
return [...firstLevelResults, ...secondLevelResults.filter((result) => result.content !== noContent)]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Markdown内容中提取URL
|
||||
*/
|
||||
private extractUrlsFromMarkdown(markdown: string): string[] {
|
||||
const urls: Set<string> = new Set()
|
||||
|
||||
// 匹配Markdown链接格式 [text](url)
|
||||
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = markdownLinkRegex.exec(markdown)) !== null) {
|
||||
const url = match[2]
|
||||
if (url && (url.startsWith('http') || url.startsWith('https'))) {
|
||||
urls.add(url)
|
||||
}
|
||||
}
|
||||
|
||||
// 匹配纯文本URL
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||
while ((match = urlRegex.exec(markdown)) !== null) {
|
||||
const url = match[1]
|
||||
if (url) {
|
||||
urls.add(url)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(urls)
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { WebSearchProvider } from '@renderer/types'
|
||||
|
||||
import BaseWebSearchProvider from './BaseWebSearchProvider'
|
||||
import DefaultProvider from './DefaultProvider'
|
||||
import DeepSearchProvider from './DeepSearchProvider'
|
||||
import ExaProvider from './ExaProvider'
|
||||
import LocalBaiduProvider from './LocalBaiduProvider'
|
||||
import LocalBingProvider from './LocalBingProvider'
|
||||
@ -24,6 +25,8 @@ export default class WebSearchProviderFactory {
|
||||
return new LocalBaiduProvider(provider)
|
||||
case 'local-bing':
|
||||
return new LocalBingProvider(provider)
|
||||
case 'deep-search':
|
||||
return new DeepSearchProvider(provider)
|
||||
default:
|
||||
return new DefaultProvider(provider)
|
||||
}
|
||||
|
||||
90
src/renderer/src/router/RouterConfig.tsx
Normal file
90
src/renderer/src/router/RouterConfig.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { createHashRouter, HashRouter, Route, Routes } from 'react-router-dom'
|
||||
import AgentsPage from '@renderer/pages/agents/AgentsPage'
|
||||
import AppsPage from '@renderer/pages/apps/AppsPage'
|
||||
import FilesPage from '@renderer/pages/files/FilesPage'
|
||||
import HomePage from '@renderer/pages/home/HomePage'
|
||||
import KnowledgePage from '@renderer/pages/knowledge/KnowledgePage'
|
||||
import PaintingsPage from '@renderer/pages/paintings/PaintingsPage'
|
||||
import SettingsPage from '@renderer/pages/settings/SettingsPage'
|
||||
import TranslatePage from '@renderer/pages/translate/TranslatePage'
|
||||
import WorkspacePage from '@renderer/pages/workspace'
|
||||
import NavigationHandler from '@renderer/handler/NavigationHandler'
|
||||
import Sidebar from '@renderer/components/app/Sidebar'
|
||||
|
||||
// 添加React Router v7的未来标志
|
||||
export const router = createHashRouter(
|
||||
[
|
||||
{
|
||||
path: '/',
|
||||
element: <HomePage />
|
||||
},
|
||||
{
|
||||
path: '/agents',
|
||||
element: <AgentsPage />
|
||||
},
|
||||
{
|
||||
path: '/paintings',
|
||||
element: <PaintingsPage />
|
||||
},
|
||||
{
|
||||
path: '/translate',
|
||||
element: <TranslatePage />
|
||||
},
|
||||
{
|
||||
path: '/files',
|
||||
element: <FilesPage />
|
||||
},
|
||||
{
|
||||
path: '/knowledge',
|
||||
element: <KnowledgePage />
|
||||
},
|
||||
{
|
||||
path: '/apps',
|
||||
element: <AppsPage />
|
||||
},
|
||||
{
|
||||
path: '/workspace',
|
||||
element: <WorkspacePage />
|
||||
},
|
||||
{
|
||||
path: '/settings/*',
|
||||
element: <SettingsPage />
|
||||
}
|
||||
],
|
||||
{
|
||||
// 添加React Router v7的未来标志
|
||||
future: {
|
||||
// @ts-ignore - v7_startTransition 在当前类型定义中不存在,但在新版本中可能存在
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 兼容现有的HashRouter实现
|
||||
export const RouterComponent = ({ children }: { children?: React.ReactNode }) => {
|
||||
return (
|
||||
<HashRouter future={{
|
||||
// @ts-ignore - v7_startTransition 在当前类型定义中不存在,但在新版本中可能存在
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true
|
||||
}}>
|
||||
<NavigationHandler />
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings" element={<PaintingsPage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/workspace" element={<WorkspacePage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
{children}
|
||||
</HashRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouterComponent
|
||||
@ -82,9 +82,14 @@ export async function fetchChatCompletion({
|
||||
|
||||
try {
|
||||
// 等待关键词生成完成
|
||||
const tools: string[] = []
|
||||
|
||||
if (assistant.enableWebSearch) tools.push('websearch')
|
||||
if (hasKnowledgeBase) tools.push('knowledge')
|
||||
|
||||
const searchSummaryAssistant = getDefaultAssistant()
|
||||
searchSummaryAssistant.model = assistant.model || getDefaultModel()
|
||||
searchSummaryAssistant.prompt = SEARCH_SUMMARY_PROMPT
|
||||
searchSummaryAssistant.prompt = SEARCH_SUMMARY_PROMPT.replace('{tools}', tools.join(', '))
|
||||
|
||||
// 如果启用搜索增强模式,则使用搜索增强模式
|
||||
if (WebSearchService.isEnhanceModeEnabled()) {
|
||||
|
||||
@ -19,7 +19,7 @@ import FileManager from './FileManager'
|
||||
export const filterMessages = (messages: Message[]) => {
|
||||
return messages
|
||||
.filter((message) => !['@', 'clear'].includes(message.type!))
|
||||
.filter((message) => !isEmpty(message.content.trim()))
|
||||
.filter((message) => message.content && !isEmpty(message.content.trim()))
|
||||
}
|
||||
|
||||
export function filterContextMessages(messages: Message[]): Message[] {
|
||||
@ -46,10 +46,10 @@ export function filterEmptyMessages(messages: Message[]): Message[] {
|
||||
return messages.filter((message) => {
|
||||
const content = message.content as string | any[]
|
||||
if (typeof content === 'string' && isEmpty(message.files)) {
|
||||
return !isEmpty(content.trim())
|
||||
return content && !isEmpty(content.trim())
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.some((c) => !isEmpty(c.text.trim()))
|
||||
return content.some((c) => c.text && !isEmpty(c.text.trim()))
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
@ -9,7 +9,7 @@ export function processReqMessages(
|
||||
return reqMessages
|
||||
}
|
||||
|
||||
return mergeSameRoleMessages(reqMessages)
|
||||
return interleaveUserAndAssistantMessages(reqMessages)
|
||||
}
|
||||
|
||||
function needStrictlyInterleaveUserAndAssistantMessages(model: Model) {
|
||||
@ -17,32 +17,28 @@ function needStrictlyInterleaveUserAndAssistantMessages(model: Model) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge successive messages with the same role
|
||||
* Interleave user and assistant messages to ensure no consecutive messages with the same role
|
||||
*/
|
||||
function mergeSameRoleMessages(messages: ChatCompletionMessageParam[]): ChatCompletionMessageParam[] {
|
||||
const split = '\n'
|
||||
const processedMessages: ChatCompletionMessageParam[] = []
|
||||
let currentGroup: ChatCompletionMessageParam[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
if (currentGroup.length === 0 || currentGroup[0].role === message.role) {
|
||||
currentGroup.push(message)
|
||||
} else {
|
||||
// merge the current group and add to processed messages
|
||||
processedMessages.push({
|
||||
...currentGroup[0],
|
||||
content: currentGroup.map((m) => m.content).join(split)
|
||||
})
|
||||
currentGroup = [message]
|
||||
}
|
||||
function interleaveUserAndAssistantMessages(messages: ChatCompletionMessageParam[]): ChatCompletionMessageParam[] {
|
||||
if (!messages || messages.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// process the last group
|
||||
if (currentGroup.length > 0) {
|
||||
processedMessages.push({
|
||||
...currentGroup[0],
|
||||
content: currentGroup.map((m) => m.content).join(split)
|
||||
})
|
||||
const processedMessages: ChatCompletionMessageParam[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const currentMessage = { ...messages[i] }
|
||||
|
||||
if (i > 0 && currentMessage.role === messages[i-1].role) {
|
||||
// insert an empty message with the opposite role in between
|
||||
const emptyMessageRole = currentMessage.role === 'user' ? 'assistant' : 'user'
|
||||
processedMessages.push({
|
||||
role: emptyMessageRole,
|
||||
content: ''
|
||||
})
|
||||
}
|
||||
|
||||
processedMessages.push(currentMessage)
|
||||
}
|
||||
|
||||
return processedMessages
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import WebSearchEngineProvider from '@renderer/providers/WebSearchProvider'
|
||||
import store from '@renderer/store'
|
||||
import { setDefaultProvider, WebSearchState } from '@renderer/store/websearch'
|
||||
import { addWebSearchProvider, setDefaultProvider, WebSearchState } from '@renderer/store/websearch'
|
||||
import { WebSearchProvider, WebSearchResponse } from '@renderer/types'
|
||||
import { hasObjectKey } from '@renderer/utils'
|
||||
import dayjs from 'dayjs'
|
||||
@ -9,12 +9,48 @@ import dayjs from 'dayjs'
|
||||
* 提供网络搜索相关功能的服务类
|
||||
*/
|
||||
class WebSearchService {
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* 确保DeepSearch供应商存在于列表中
|
||||
* @private
|
||||
*/
|
||||
private ensureDeepSearchProvider(): void {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
const state = store.getState();
|
||||
if (!state || !state.websearch) return;
|
||||
|
||||
const { providers } = state.websearch;
|
||||
if (!providers) return;
|
||||
|
||||
const deepSearchExists = providers.some(provider => provider.id === 'deep-search');
|
||||
|
||||
if (!deepSearchExists) {
|
||||
console.log('[WebSearchService] 添加DeepSearch供应商到列表');
|
||||
store.dispatch(addWebSearchProvider({
|
||||
id: 'deep-search',
|
||||
name: 'DeepSearch (多引擎)',
|
||||
description: '使用Baidu、Bing、DuckDuckGo、搜狗和SearX进行深度搜索',
|
||||
contentLimit: 10000
|
||||
}));
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
console.error('[WebSearchService] 初始化DeepSearch失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前存储的网络搜索状态
|
||||
* @private
|
||||
* @returns 网络搜索状态
|
||||
*/
|
||||
private getWebSearchState(): WebSearchState {
|
||||
// 确保DeepSearch供应商存在
|
||||
this.ensureDeepSearchProvider();
|
||||
return store.getState().websearch
|
||||
}
|
||||
|
||||
@ -31,7 +67,8 @@ class WebSearchService {
|
||||
return false
|
||||
}
|
||||
|
||||
if (provider.id.startsWith('local-')) {
|
||||
// DeepSearch和本地搜索引擎总是可用的
|
||||
if (provider.id === 'deep-search' || provider.id.startsWith('local-')) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -139,8 +176,22 @@ class WebSearchService {
|
||||
* @throws 如果文本中没有question标签则抛出错误
|
||||
*/
|
||||
public extractInfoFromXML(text: string): { question: string; links?: string[] } {
|
||||
// 提取工具标签内容
|
||||
let questionText = text
|
||||
|
||||
// 先检查是否有工具标签
|
||||
const websearchMatch = text.match(/<websearch>([\s\S]*?)<\/websearch>/)
|
||||
const knowledgeMatch = text.match(/<knowledge>([\s\S]*?)<\/knowledge>/)
|
||||
|
||||
// 如果有工具标签,使用工具标签内的内容
|
||||
if (websearchMatch) {
|
||||
questionText = websearchMatch[1]
|
||||
} else if (knowledgeMatch) {
|
||||
questionText = knowledgeMatch[1]
|
||||
}
|
||||
|
||||
// 提取question标签内容
|
||||
const questionMatch = text.match(/<question>([\s\S]*?)<\/question>/)
|
||||
const questionMatch = questionText.match(/<question>([\s\S]*?)<\/question>/) || text.match(/<question>([\s\S]*?)<\/question>/)
|
||||
if (!questionMatch) {
|
||||
throw new Error('Missing required <question> tag')
|
||||
}
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import assert from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { ChatCompletionMessageParam } from 'openai/resources'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const { processReqMessages } = require('../ModelMessageService')
|
||||
import { processReqMessages } from '../ModelMessageService'
|
||||
|
||||
test('ModelMessageService', async (t) => {
|
||||
describe('ModelMessageService', () => {
|
||||
const mockMessages: ChatCompletionMessageParam[] = [
|
||||
{ role: 'user', content: 'First question' },
|
||||
{ role: 'user', content: 'Additional context' },
|
||||
@ -15,83 +13,73 @@ test('ModelMessageService', async (t) => {
|
||||
{ role: 'assistant', content: 'Second answer' }
|
||||
]
|
||||
|
||||
await t.test('should merge successive messages with same role for deepseek-reasoner model', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
it('should interleave messages with same role for deepseek-reasoner model', () => {
|
||||
const model = { id: 'deepseek-reasoner', provider: 'test', name: 'Test Model', group: 'Test' }
|
||||
const result = processReqMessages(model, mockMessages)
|
||||
|
||||
assert.strictEqual(result.length, 4)
|
||||
assert.deepStrictEqual(result[0], {
|
||||
// Expected result should have empty messages inserted between consecutive messages of the same role
|
||||
expect(result.length).toBe(9)
|
||||
expect(result[0]).toEqual({
|
||||
role: 'user',
|
||||
content: 'First question\nAdditional context'
|
||||
content: 'First question'
|
||||
})
|
||||
assert.deepStrictEqual(result[1], {
|
||||
expect(result[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'First answer\nAdditional information'
|
||||
content: ''
|
||||
})
|
||||
assert.deepStrictEqual(result[2], {
|
||||
expect(result[2]).toEqual({
|
||||
role: 'user',
|
||||
content: 'Additional context'
|
||||
})
|
||||
expect(result[3]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'First answer'
|
||||
})
|
||||
expect(result[4]).toEqual({
|
||||
role: 'user',
|
||||
content: ''
|
||||
})
|
||||
expect(result[5]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'Additional information'
|
||||
})
|
||||
expect(result[6]).toEqual({
|
||||
role: 'user',
|
||||
content: 'Second question'
|
||||
})
|
||||
assert.deepStrictEqual(result[3], {
|
||||
expect(result[7]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'Second answer'
|
||||
})
|
||||
})
|
||||
|
||||
await t.test('should not merge messages for other models', () => {
|
||||
const model = { id: 'gpt-4' }
|
||||
it('should not modify messages for other models', () => {
|
||||
const model = { id: 'gpt-4', provider: 'test', name: 'Test Model', group: 'Test' }
|
||||
const result = processReqMessages(model, mockMessages)
|
||||
|
||||
assert.strictEqual(result.length, mockMessages.length)
|
||||
assert.deepStrictEqual(result, mockMessages)
|
||||
expect(result.length).toBe(mockMessages.length)
|
||||
expect(result).toEqual(mockMessages)
|
||||
})
|
||||
|
||||
await t.test('should handle empty messages array', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
it('should handle empty messages array', () => {
|
||||
const model = { id: 'deepseek-reasoner', provider: 'test', name: 'Test Model', group: 'Test' }
|
||||
const result = processReqMessages(model, [])
|
||||
|
||||
assert.strictEqual(result.length, 0)
|
||||
assert.deepStrictEqual(result, [])
|
||||
expect(result.length).toBe(0)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
await t.test('should handle single message', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
const singleMessage = [{ role: 'user', content: 'Single message' }]
|
||||
it('should handle single message', () => {
|
||||
const model = { id: 'deepseek-reasoner', provider: 'test', name: 'Test Model', group: 'Test' }
|
||||
const singleMessage = [{ role: 'user', content: 'Single message' }] as ChatCompletionMessageParam[]
|
||||
const result = processReqMessages(model, singleMessage)
|
||||
|
||||
assert.strictEqual(result.length, 1)
|
||||
assert.deepStrictEqual(result, singleMessage)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result).toEqual(singleMessage)
|
||||
})
|
||||
|
||||
await t.test('should preserve other message properties when merging', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
const messagesWithProps = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'First message',
|
||||
name: 'user1',
|
||||
function_call: { name: 'test', arguments: '{}' }
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Second message',
|
||||
name: 'user1'
|
||||
}
|
||||
] as ChatCompletionMessageParam[]
|
||||
|
||||
const result = processReqMessages(model, messagesWithProps)
|
||||
|
||||
assert.strictEqual(result.length, 1)
|
||||
assert.deepStrictEqual(result[0], {
|
||||
role: 'user',
|
||||
content: 'First message\nSecond message',
|
||||
name: 'user1',
|
||||
function_call: { name: 'test', arguments: '{}' }
|
||||
})
|
||||
})
|
||||
|
||||
await t.test('should handle alternating roles correctly', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
it('should handle alternating roles correctly', () => {
|
||||
const model = { id: 'deepseek-reasoner', provider: 'test', name: 'Test Model', group: 'Test' }
|
||||
const alternatingMessages = [
|
||||
{ role: 'user', content: 'Q1' },
|
||||
{ role: 'assistant', content: 'A1' },
|
||||
@ -101,12 +89,13 @@ test('ModelMessageService', async (t) => {
|
||||
|
||||
const result = processReqMessages(model, alternatingMessages)
|
||||
|
||||
assert.strictEqual(result.length, 4)
|
||||
assert.deepStrictEqual(result, alternatingMessages)
|
||||
// Alternating roles should remain unchanged
|
||||
expect(result.length).toBe(4)
|
||||
expect(result).toEqual(alternatingMessages)
|
||||
})
|
||||
|
||||
await t.test('should handle messages with empty content', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
it('should handle messages with empty content', () => {
|
||||
const model = { id: 'deepseek-reasoner', provider: 'test', name: 'Test Model', group: 'Test' }
|
||||
const messagesWithEmpty = [
|
||||
{ role: 'user', content: 'Q1' },
|
||||
{ role: 'user', content: '' },
|
||||
@ -115,10 +104,12 @@ test('ModelMessageService', async (t) => {
|
||||
|
||||
const result = processReqMessages(model, messagesWithEmpty)
|
||||
|
||||
assert.strictEqual(result.length, 1)
|
||||
assert.deepStrictEqual(result[0], {
|
||||
role: 'user',
|
||||
content: 'Q1\n\nQ2'
|
||||
})
|
||||
// Should insert empty assistant messages between consecutive user messages
|
||||
expect(result.length).toBe(5)
|
||||
expect(result[0]).toEqual({ role: 'user', content: 'Q1' })
|
||||
expect(result[1]).toEqual({ role: 'assistant', content: '' })
|
||||
expect(result[2]).toEqual({ role: 'user', content: '' })
|
||||
expect(result[3]).toEqual({ role: 'assistant', content: '' })
|
||||
expect(result[4]).toEqual({ role: 'user', content: 'Q2' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -12,7 +12,6 @@ import llm from './llm'
|
||||
import mcp from './mcp'
|
||||
import memory from './memory' // Removed import of memoryPersistenceMiddleware
|
||||
import messagesReducer from './messages'
|
||||
import workspace from './workspace'
|
||||
import migrate from './migrate'
|
||||
import minapps from './minapps'
|
||||
import nutstore from './nutstore'
|
||||
@ -21,6 +20,7 @@ import runtime from './runtime'
|
||||
import settings from './settings'
|
||||
import shortcuts from './shortcuts'
|
||||
import websearch from './websearch'
|
||||
import workspace from './workspace'
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
assistants,
|
||||
|
||||
@ -116,6 +116,13 @@ export const builtinMCPServers: MCPServer[] = [
|
||||
WORKSPACE_PATH: ''
|
||||
},
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: '@cherry/timetools',
|
||||
type: 'inMemory',
|
||||
description: '时间工具,提供获取当前系统时间的功能,允许AI随时知道当前日期和时间。',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -61,6 +61,7 @@ export interface SettingsState {
|
||||
autoCheckUpdate: boolean
|
||||
renderInputMessageAsMarkdown: boolean
|
||||
enableHistoricalContext: boolean // 是否启用历史对话上下文功能
|
||||
usePromptForToolCalling: boolean // 是否使用提示词而非函数调用来调用MCP工具
|
||||
codeShowLineNumbers: boolean
|
||||
codeCollapsible: boolean
|
||||
codeWrappable: boolean
|
||||
@ -222,6 +223,7 @@ export const initialState: SettingsState = {
|
||||
autoCheckUpdate: true,
|
||||
renderInputMessageAsMarkdown: false,
|
||||
enableHistoricalContext: false, // 默认禁用历史对话上下文功能
|
||||
usePromptForToolCalling: true, // 默认使用提示词而非函数调用来调用MCP工具
|
||||
codeShowLineNumbers: false,
|
||||
codeCollapsible: false,
|
||||
codeWrappable: false,
|
||||
@ -349,6 +351,9 @@ const settingsSlice = createSlice({
|
||||
name: 'settings',
|
||||
initialState,
|
||||
reducers: {
|
||||
setUsePromptForToolCalling: (state, action: PayloadAction<boolean>) => {
|
||||
state.usePromptForToolCalling = action.payload
|
||||
},
|
||||
setShowAssistants: (state, action: PayloadAction<boolean>) => {
|
||||
state.showAssistants = action.payload
|
||||
},
|
||||
@ -936,7 +941,8 @@ export const {
|
||||
setIsVoiceCallActive,
|
||||
setLastPlayedMessageId,
|
||||
setSkipNextAutoTTS,
|
||||
setEnableBackspaceDeleteModel
|
||||
setEnableBackspaceDeleteModel,
|
||||
setUsePromptForToolCalling
|
||||
} = settingsSlice.actions
|
||||
|
||||
// PDF设置相关的action
|
||||
|
||||
@ -58,10 +58,16 @@ const initialState: WebSearchState = {
|
||||
id: 'local-baidu',
|
||||
name: 'Baidu',
|
||||
url: 'https://www.baidu.com/s?wd=%s'
|
||||
},
|
||||
{
|
||||
id: 'deep-search',
|
||||
name: 'DeepSearch (多引擎)',
|
||||
description: '使用Baidu、Bing、DuckDuckGo、搜狗和SearX进行深度搜索',
|
||||
contentLimit: 10000
|
||||
}
|
||||
],
|
||||
searchWithTime: true,
|
||||
maxResults: 5,
|
||||
maxResults: 100,
|
||||
excludeDomains: [],
|
||||
subscribeSources: [],
|
||||
enhanceMode: true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { RootState } from '@renderer/store'
|
||||
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import db from '@renderer/databases'
|
||||
import { RootState } from '@renderer/store'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
// 工作区类型定义
|
||||
@ -14,7 +14,7 @@ export interface Workspace {
|
||||
}
|
||||
|
||||
// 工作区状态
|
||||
interface WorkspaceState {
|
||||
export interface WorkspaceState {
|
||||
workspaces: Workspace[]
|
||||
currentWorkspaceId: string | null
|
||||
isLoading: boolean
|
||||
@ -55,9 +55,9 @@ const workspaceSlice = createSlice({
|
||||
},
|
||||
|
||||
// 更新工作区
|
||||
updateWorkspace: (state, action: PayloadAction<{ id: string, workspace: Partial<Workspace> }>) => {
|
||||
updateWorkspace: (state, action: PayloadAction<{ id: string; workspace: Partial<Workspace> }>) => {
|
||||
const { id, workspace } = action.payload
|
||||
const index = state.workspaces.findIndex(w => w.id === id)
|
||||
const index = state.workspaces.findIndex((w) => w.id === id)
|
||||
|
||||
if (index !== -1) {
|
||||
state.workspaces[index] = {
|
||||
@ -77,7 +77,7 @@ const workspaceSlice = createSlice({
|
||||
// 删除工作区
|
||||
removeWorkspace: (state, action: PayloadAction<string>) => {
|
||||
const id = action.payload
|
||||
state.workspaces = state.workspaces.filter(w => w.id !== id)
|
||||
state.workspaces = state.workspaces.filter((w) => w.id !== id)
|
||||
|
||||
// 如果删除的是当前工作区,重置当前工作区
|
||||
if (state.currentWorkspaceId === id) {
|
||||
@ -126,17 +126,25 @@ export const {
|
||||
// 选择器
|
||||
export const selectWorkspaces = (state: RootState) => state.workspace.workspaces
|
||||
export const selectCurrentWorkspaceId = (state: RootState) => state.workspace.currentWorkspaceId
|
||||
export const selectCurrentWorkspace = (state: RootState) => {
|
||||
const { currentWorkspaceId, workspaces } = state.workspace
|
||||
return currentWorkspaceId ? workspaces.find(w => w.id === currentWorkspaceId) || null : null
|
||||
}
|
||||
|
||||
// 使用createSelector记忆化选择器
|
||||
export const selectCurrentWorkspace = createSelector(
|
||||
[(state: RootState) => state.workspace.currentWorkspaceId, (state: RootState) => state.workspace.workspaces],
|
||||
(currentWorkspaceId, workspaces) => {
|
||||
return currentWorkspaceId ? workspaces.find((w) => w.id === currentWorkspaceId) || null : null
|
||||
}
|
||||
)
|
||||
|
||||
export const selectIsLoading = (state: RootState) => state.workspace.isLoading
|
||||
export const selectError = (state: RootState) => state.workspace.error
|
||||
|
||||
// 选择对AI可见的工作区
|
||||
export const selectVisibleToAIWorkspaces = (state: RootState) => {
|
||||
return state.workspace.workspaces.filter(w => w.visibleToAI !== false) // 如果visibleToAI为undefined或true,则返回
|
||||
}
|
||||
export const selectVisibleToAIWorkspaces = createSelector(
|
||||
[(state: RootState) => state.workspace.workspaces],
|
||||
(workspaces) => {
|
||||
return workspaces.filter((w) => w.visibleToAI !== false) // 如果visibleToAI为undefined或true,则返回
|
||||
}
|
||||
)
|
||||
|
||||
// 导出 reducer
|
||||
export default workspaceSlice.reducer
|
||||
@ -151,7 +159,7 @@ export const initWorkspaces = () => async (dispatch: any) => {
|
||||
|
||||
// 检查并设置默认的visibleToAI属性
|
||||
let needsUpdate = false
|
||||
workspaces = workspaces.map(workspace => {
|
||||
workspaces = workspaces.map((workspace) => {
|
||||
if (workspace.visibleToAI === undefined) {
|
||||
needsUpdate = true
|
||||
// 默认只有第一个工作区对AI可见
|
||||
@ -176,7 +184,7 @@ export const initWorkspaces = () => async (dispatch: any) => {
|
||||
|
||||
// 从本地存储获取当前工作区ID
|
||||
const currentWorkspaceId = localStorage.getItem('currentWorkspaceId')
|
||||
if (currentWorkspaceId && workspaces.some(w => w.id === currentWorkspaceId)) {
|
||||
if (currentWorkspaceId && workspaces.some((w) => w.id === currentWorkspaceId)) {
|
||||
dispatch(setCurrentWorkspace(currentWorkspaceId))
|
||||
} else if (workspaces.length > 0) {
|
||||
dispatch(setCurrentWorkspace(workspaces[0].id))
|
||||
|
||||
53
src/renderer/src/styles/translation.css
Normal file
53
src/renderer/src/styles/translation.css
Normal file
@ -0,0 +1,53 @@
|
||||
/* 翻译标签的样式 */
|
||||
.translated-text {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* 鼠标悬停时显示提示 */
|
||||
.translated-text:not([data-showing-original="true"]):hover::after {
|
||||
content: "点击查看原文";
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 原文悬停提示 */
|
||||
.translated-text[data-showing-original="true"]:hover::after {
|
||||
content: "点击查看翻译";
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 原文的样式 */
|
||||
.translated-text[data-showing-original="true"] {
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 用户消息样式 */
|
||||
.user-message-content {
|
||||
margin-bottom: 5px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@ -373,6 +373,7 @@ export type WebSearchProvider = {
|
||||
url?: string
|
||||
contentLimit?: number
|
||||
usingBrowser?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type WebSearchResponse = {
|
||||
@ -384,6 +385,10 @@ export type WebSearchResult = {
|
||||
title: string
|
||||
content: string
|
||||
url: string
|
||||
source?: string
|
||||
summary?: string
|
||||
keywords?: string[]
|
||||
relevanceScore?: number
|
||||
}
|
||||
|
||||
export type KnowledgeReference = {
|
||||
|
||||
@ -20,6 +20,8 @@ export function escapeDollarNumber(text: string) {
|
||||
}
|
||||
|
||||
export function escapeBrackets(text: string) {
|
||||
if (!text) return ''
|
||||
|
||||
const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g
|
||||
return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => {
|
||||
if (codeBlock) {
|
||||
@ -62,6 +64,11 @@ export function removeSvgEmptyLines(text: string): string {
|
||||
}
|
||||
|
||||
export function withGeminiGrounding(message: Message) {
|
||||
// 检查消息内容是否为空或未定义
|
||||
if (message.content === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const { groundingSupports } = message?.metadata?.groundingMetadata || {}
|
||||
|
||||
if (!groundingSupports) {
|
||||
@ -161,6 +168,12 @@ export function withMessageThought(message: Message) {
|
||||
return message
|
||||
}
|
||||
|
||||
// 检查消息内容是否为空或未定义
|
||||
if (message.content === undefined) {
|
||||
message.content = ''
|
||||
return message
|
||||
}
|
||||
|
||||
const model = message.model
|
||||
if (!model || !isReasoningModel(model)) return message
|
||||
|
||||
@ -184,6 +197,11 @@ export function withMessageThought(message: Message) {
|
||||
}
|
||||
|
||||
export function withGenerateImage(message: Message) {
|
||||
// 检查消息内容是否为空或未定义
|
||||
if (message.content === undefined) {
|
||||
message.content = ''
|
||||
return message
|
||||
}
|
||||
const imagePattern = new RegExp(`!\\[[^\\]]*\\]\\((.*?)\\s*("(?:.*[^"])")?\\s*\\)`)
|
||||
const imageMatches = message.content.match(imagePattern)
|
||||
|
||||
|
||||
@ -62,14 +62,15 @@ export const MARKDOWN_ALLOWED_TAGS = [
|
||||
'tspan',
|
||||
'sub',
|
||||
'sup',
|
||||
'think'
|
||||
'think',
|
||||
'translated' // 添加自定义翻译标签
|
||||
]
|
||||
|
||||
// rehype-sanitize配置
|
||||
export const sanitizeSchema = {
|
||||
tagNames: MARKDOWN_ALLOWED_TAGS,
|
||||
attributes: {
|
||||
'*': ['className', 'style', 'id', 'title'],
|
||||
'*': ['className', 'style', 'id', 'title', 'data-*'],
|
||||
svg: ['viewBox', 'width', 'height', 'xmlns', 'fill', 'stroke'],
|
||||
path: ['d', 'fill', 'stroke', 'strokeWidth', 'strokeLinecap', 'strokeLinejoin'],
|
||||
circle: ['cx', 'cy', 'r', 'fill', 'stroke'],
|
||||
@ -79,6 +80,7 @@ export const sanitizeSchema = {
|
||||
polygon: ['points', 'fill', 'stroke'],
|
||||
text: ['x', 'y', 'fill', 'textAnchor', 'dominantBaseline'],
|
||||
g: ['transform', 'fill', 'stroke'],
|
||||
a: ['href', 'target', 'rel']
|
||||
a: ['href', 'target', 'rel'],
|
||||
translated: ['original', 'language', 'onclick'] // 添加翻译标签的属性
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import store from '@renderer/store'
|
||||
import { MCPTool } from '@renderer/types'
|
||||
|
||||
export const SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \
|
||||
@ -206,21 +207,46 @@ export const buildSystemPrompt = async (
|
||||
const enhancedPrompt = appMemoriesPrompt + (mcpMemoriesPrompt ? `\n\n${mcpMemoriesPrompt}` : '')
|
||||
|
||||
let finalPrompt: string
|
||||
// When using native function calling (tools are present), the system prompt should only contain
|
||||
// the user's instructions and any relevant memories. The model learns how to call tools
|
||||
// from the 'tools' parameter in the API request, not from XML instructions in the prompt.
|
||||
// 检查是否有工具可用
|
||||
if (tools && tools.length > 0) {
|
||||
console.log('[Prompt] Building prompt for native function calling:', { promptLength: enhancedPrompt.length })
|
||||
// 添加强化工具使用的提示词
|
||||
finalPrompt = GEMINI_TOOL_PROMPT + '\n\n' + enhancedPrompt
|
||||
console.log('[Prompt] Added tool usage enhancement prompt')
|
||||
// 获取当前的usePromptForToolCalling设置
|
||||
const usePromptForToolCalling = store.getState().settings.usePromptForToolCalling
|
||||
|
||||
// 获取当前的provider类型(从OpenAIProvider.ts调用时为OpenAI,从GeminiProvider.ts调用时为Gemini)
|
||||
// 这里我们通过调用栈来判断是哪个provider调用的
|
||||
const callStack = new Error().stack || ''
|
||||
const isOpenAIProvider = callStack.includes('OpenAIProvider')
|
||||
const isGeminiProvider = callStack.includes('GeminiProvider')
|
||||
|
||||
console.log('[Prompt] Building prompt for tools:', {
|
||||
promptLength: enhancedPrompt.length,
|
||||
usePromptForToolCalling,
|
||||
isOpenAIProvider,
|
||||
isGeminiProvider
|
||||
})
|
||||
|
||||
if (isOpenAIProvider && usePromptForToolCalling) {
|
||||
// 对于OpenAI,使用SYSTEM_PROMPT模板,并替换占位符
|
||||
const openAIToolPrompt = SYSTEM_PROMPT.replace('{{ TOOL_USE_EXAMPLES }}', ToolUseExamples)
|
||||
.replace('{{ AVAILABLE_TOOLS }}', AvailableTools(tools))
|
||||
.replace('{{ USER_SYSTEM_PROMPT }}', enhancedPrompt)
|
||||
|
||||
console.log('[Prompt] Using OpenAI tool prompt with examples')
|
||||
finalPrompt = openAIToolPrompt
|
||||
} else if (isGeminiProvider) {
|
||||
// 对于Gemini,使用GEMINI_TOOL_PROMPT
|
||||
finalPrompt = GEMINI_TOOL_PROMPT + '\n\n' + enhancedPrompt
|
||||
console.log('[Prompt] Added Gemini tool usage enhancement prompt')
|
||||
} else {
|
||||
// 默认情况,直接使用增强的提示词
|
||||
finalPrompt = enhancedPrompt
|
||||
console.log('[Prompt] Using enhanced prompt without tool instructions')
|
||||
}
|
||||
} else {
|
||||
console.log('[Prompt] Building prompt without tools (or for XML tool use):', {
|
||||
console.log('[Prompt] Building prompt without tools:', {
|
||||
promptLength: enhancedPrompt.length
|
||||
})
|
||||
// If no tools are provided (or if a model doesn't support native calls and relies on XML),
|
||||
// we might still need the old SYSTEM_PROMPT logic. For now, assume no tools means no tool instructions needed.
|
||||
// If XML fallback is needed later, this 'else' block might need to re-introduce SYSTEM_PROMPT.
|
||||
// 如果没有工具,直接使用增强的提示词
|
||||
finalPrompt = enhancedPrompt
|
||||
}
|
||||
// Single return point for the function
|
||||
|
||||
Loading…
Reference in New Issue
Block a user