This commit is contained in:
1600822305 2025-04-23 23:35:37 +08:00
parent 4dde843ef4
commit 7faf8ec27b
50 changed files with 4354 additions and 359 deletions

909
OpenAIProvider.ts Normal file
View 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
}
}

View 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
};

View File

@ -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",

View File

@ -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}`)
}

View 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

View File

@ -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
}
}

View File

@ -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>

View File

@ -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)

View File

@ -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}
/>
)
}

View File

@ -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>

View 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

View File

@ -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}>

View File

@ -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: [

View File

@ -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>

View File

@ -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?",

View File

@ -1988,6 +1988,7 @@
},
"error.failed": "翻译失败",
"error.not_configured": "翻译模型未配置",
"success": "翻译成功",
"history": {
"clear": "清空历史",
"clear_description": "清空历史将删除所有翻译历史记录,是否继续?",

View File

@ -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

View File

@ -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>')) {

View File

@ -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(/&quot;/g, '"')
}
}
// 执行翻译
const translatedText = await translateText(originalText, language)
// 如果是选中的文本,直接替换原文中的选中部分
if (selection) {
// 不再需要存储翻译映射关系
// 替换消息内容中的选中部分
const newContent =
message.content.substring(0, selection.start) +
`<translated original="${originalText.replace(/"/g, '&quot;')}" 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;

View File

@ -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' ? (

View File

@ -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>

View File

@ -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 && (

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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={

View File

@ -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

View File

@ -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

View File

@ -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}`)

View File

@ -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),

View File

@ -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)
}
}

View File

@ -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)
}

View 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

View File

@ -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()) {

View File

@ -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
})

View File

@ -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

View File

@ -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')
}

View File

@ -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' })
})
})

View File

@ -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,

View File

@ -116,6 +116,13 @@ export const builtinMCPServers: MCPServer[] = [
WORKSPACE_PATH: ''
},
isActive: false
},
{
id: nanoid(),
name: '@cherry/timetools',
type: 'inMemory',
description: '时间工具提供获取当前系统时间的功能允许AI随时知道当前日期和时间。',
isActive: true
}
]

View File

@ -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

View File

@ -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,

View File

@ -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))

View 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;
}

View File

@ -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 = {

View File

@ -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)

View File

@ -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'] // 添加翻译标签的属性
}
}

View File

@ -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

1067
yarn.lock

File diff suppressed because it is too large Load Diff