修复升级到32.3.3

This commit is contained in:
1600822305 2025-04-28 06:55:18 +08:00
parent e5c3e57430
commit e2236b48a6
159 changed files with 231789 additions and 4815 deletions

View File

@ -1,7 +1,13 @@
# 基本配置
enableImmutableInstalls: false
nodeLinker: node-modules
httpTimeout: 300000
nodeLinker: node-modules
# npm 镜像配置
npmRegistryServer: "https://registry.npmmirror.com"
yarnPath: .yarn/releases/yarn-4.6.0.cjs
# 注意Yarn 4.x 不支持在 .yarnrc.yml 中设置环境变量
# 请使用命令行设置 ELECTRON_MIRROR 环境变量
# Yarn 路径
yarnPath: .yarn/releases/yarn-4.6.0.cjs

View File

@ -1,909 +0,0 @@
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 { mcpToolCallResponseToOpenAIMessage, parseAndCallTools } from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks, getMessageContent } from '@renderer/utils/messageUtils/find'
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
}
}

74
browser_logs.txt Normal file
View File

@ -0,0 +1,74 @@
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=lgZ0TYM3KRXU…l1cvzRgzjWM-1745729582-1.0.1.1-_Q9WkYircZ9.sJjbmssTZZ1o3mYuv4Ow2wIMeuvsSCo'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM'
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM'
3
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=INZqycV.To1s…ncOZ60qZVcE-1745729582-1.0.1.1-UL04FD3iguMiqRuBPofPqfu9U_q7Odl1ms9vmH6gl9c, title: 请稍候…
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: 请稍候…
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=INZqycV.To1s…ncOZ60qZVcE-1745729582-1.0.1.1-UL04FD3iguMiqRuBPofPqfu9U_q7Odl1ms9vmH6gl9c'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:382 [Tab tab-1745729188027] New window request: undefined, frameName: 未指定, linkOpenMode: newTab
useAnimatedTabs.ts:37 [openUrlInTab] Called with url: undefined, inNewTab: true, title: 加载中...
useAnimatedTabs.ts:41 [openUrlInTab] Called with undefined or empty URL, ignoring.
useWebviewEvents.ts:87 [Tab tab-1745729188027] Navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM, title: Just a moment...
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: Just a moment...
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y, title: Just a moment...
2
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
2
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM, title: 请稍候…
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: 请稍候…
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM'
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:382 [Tab tab-1745729188027] New window request: undefined, frameName: 未指定, linkOpenMode: newTab
useAnimatedTabs.ts:37 [openUrlInTab] Called with url: undefined, inNewTab: true, title: 加载中...
useAnimatedTabs.ts:41 [openUrlInTab] Called with undefined or empty URL, ignoring.
useWebviewEvents.ts:87 [Tab tab-1745729188027] Navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y, title: Just a moment...
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: Just a moment...
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM, title: Just a moment...
useWebviewEvents.ts:382 [Tab tab-1745721367810] New window request: undefined, frameName: 未指定, linkOpenMode: newTab
useAnimatedTabs.ts:37 [openUrlInTab] Called with url: undefined, inNewTab: true, title: 加载中...
useAnimatedTabs.ts:41 [openUrlInTab] Called with undefined or empty URL, ignoring.
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM'
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y, title: 请稍候…
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: 请稍候…
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:382 [Tab tab-1745729188027] New window request: undefined, frameName: 未指定, linkOpenMode: newTab
useAnimatedTabs.ts:37 [openUrlInTab] Called with url: undefined, inNewTab: true, title: 加载中...
useAnimatedTabs.ts:41 [openUrlInTab] Called with undefined or empty URL, ignoring.
useWebviewEvents.ts:87 [Tab tab-1745729188027] Navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM, title: Just a moment...
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: Just a moment...
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=HspNR8z7A2YV…r7S37QRYDJc-1745729583-1.0.1.1-A2E.8KxOq117yDjAtJ1ROmjVdhUD9cjPqi3v4pq8c7M, title: Just a moment...
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM'
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=HspNR8z7A2YV…r7S37QRYDJc-1745729583-1.0.1.1-A2E.8KxOq117yDjAtJ1ROmjVdhUD9cjPqi3v4pq8c7M'


385
browser_modifications.txt Normal file
View File

@ -0,0 +1,385 @@
# 内置浏览器功能修改指南
本文档包含对内置浏览器功能的修改说明,包括:
1. 修复新标签页无法打开的问题
2. 处理登录弹窗 (HTTP 认证)
3. 增加切换链接打开方式 (新标签页/独立窗口) 的按钮和逻辑
---
## 1. 修复新标签页无法打开的问题
**问题原因:**
初步诊断发现,新创建的 webview 元素没有正确加载 URL并且 `useWebviewEvents.ts` 文件中存在语法错误,导致事件监听器和链接点击拦截脚本未能正确执行。
**修改步骤:**
1. **修改 `src/renderer/src/pages/Browser/components/WebviewItem.tsx`:**
* 移除 `React.memo` 包裹,确保在父组件状态变化时 `WebviewItem` 总是重新渲染。
* 在 `<webview>` 元素的 `ref` 回调函数中,确保在获取到 webview 引用后,显式调用 `webview.loadURL(tab.url)` 来加载 URL。同时移除 `<webview>` 元素上的 `src` 属性,避免重复加载。
**需要修改的文件:** `src/renderer/src/pages/Browser/components/WebviewItem.tsx`
**修改内容 (使用 replace_in_file 格式):**
```
<<<<<<< SEARCH
export default React.memo(WebviewItem)
=======
export default WebviewItem
>>>>>>> REPLACE
<<<<<<< SEARCH
<webview
src={tab.url}
ref={(el: any) => {
if (el) {
// 保存webview引用到对应的tabId下
webviewRefs.current[tab.id] = el as WebviewTag
// 只有在尚未设置监听器时才设置
if (!hasSetupListenersRef.current) {
console.log(`[WebviewItem] Setting up listeners for tab: ${tab.id}`)
=======
<webview
ref={(el: any) => {
if (el) {
// 保存webview引用到对应的tabId下
webviewRefs.current[tab.id] = el as WebviewTag
// 只有在尚未设置监听器时才设置
if (!hasSetupListenersRef.current) {
console.log(`[WebviewItem] Setting up listeners for tab: ${tab.id}`)
// 显式加载URL
el.loadURL(tab.url);
>>>>>>> REPLACE
```
*(注意: 上述 diff 仅为示例,实际修改时请参考您当前文件的最新内容和格式进行调整。特别是移除 `src={tab.url}` 和添加 `el.loadURL(tab.url);`)*
2. **修改 `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts`:**
* 修复 `handleDomReady` 函数中注入的链接点击拦截脚本末尾多余的 `})();` 语法错误。
* 将本地变量 `ENABLE_BROWSER_EMULATION` 传递到注入的浏览器模拟脚本中,解决 ESLint 警告。
* 在 `handleNewWindow` 和 `handleConsoleMessage` 中添加日志,用于调试(可选,调试完成后可移除)。
* 在注入的链接点击拦截脚本中添加日志,用于调试(可选,调试完成后可移除)。
**需要修改的文件:** `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts`
**修改内容 (使用 replace_in_file 格式):**
```
<<<<<<< SEARCH
console.log('Link interceptor script injected successfully');
})();
console.log('Link interceptor script injected successfully');
})();
`)
// 注入浏览器模拟脚本 (在脚本内部检查 ENABLE_BROWSER_EMULATION)
webview.executeJavaScript(`
if (window.ENABLE_BROWSER_EMULATION) {
try {
// 覆盖navigator.userAgent
Object.defineProperty(navigator, 'userAgent', {
value: '${userAgent}',
writable: false
});
=======
console.log('Link interceptor script injected successfully');
})();
`)
// 注入浏览器模拟脚本
webview.executeJavaScript(`
(function() {
// 检查是否启用浏览器模拟
const ENABLE_BROWSER_EMULATION = ${ENABLE_BROWSER_EMULATION};
if (ENABLE_BROWSER_EMULATION) {
try {
// 覆盖navigator.userAgent
Object.defineProperty(navigator, 'userAgent', {
value: '${userAgent}',
writable: false
});
>>>>>>> REPLACE
```
*(注意: 上述 diff 仅为示例,实际修改时请参考您当前文件的最新内容和格式进行调整。特别是移除多余的 `})();` 和传递 `ENABLE_BROWSER_EMULATION` 变量)*
**预期结果:**
完成上述修改后,点击需要新标签页打开的链接应该能够成功创建一个新的标签页并加载对应的 URL。控制台应该能看到链接拦截脚本的日志。
---
## 2. 处理登录弹窗 (HTTP 认证)
**问题描述:**
当访问需要 HTTP 认证的网站时,内置浏览器没有弹出登录对话框。
**实现方案:**
Electron 的 `webview` 标签会触发 `show-login` 事件,当需要进行 HTTP 认证时。我们可以在 `useWebviewEvents.ts` 中监听这个事件,并通过 IPC 通道将认证请求发送到主进程。主进程可以显示一个原生的认证对话框,获取用户输入的用户名和密码,然后通过 IPC 将凭据返回给渲染进程,由渲染进程将凭据发送给 webview 进行认证。
**修改步骤:**
1. **在主进程中添加 IPC 处理:**
* 在主进程 (`src/main/index.ts` 或相关的 IPC 处理文件) 中,添加一个 IPC 监听器,例如 `ipcMain.handle('show-login-dialog', ...)`。
* 在这个处理函数中,使用 Electron 的 `dialog.showLoginDialog()` 方法显示认证对话框。
* 将对话框的结果(用户名和密码)通过 IPC 返回给渲染进程。
**需要修改的文件:** `src/main/index.ts` 或 IPC 处理文件
**示例代码 (主进程):**
```typescript
// src/main/index.ts 或 src/main/ipc.ts
import { ipcMain, dialog } from 'electron';
ipcMain.handle('show-login-dialog', async (event, args) => {
const { url, realm } = args;
const result = await dialog.showLoginDialog({
title: 'Authentication Required',
text: `Enter credentials for ${url}`,
message: `Realm: ${realm}`,
});
return result; // { username, password, response }
});
```
2. **在渲染进程中添加 IPC 调用和 `show-login` 事件处理:**
* 在 `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts` 中,添加 `show-login` 事件监听器。
* 在 `handleShowLogin` 函数中,通过 `window.api.invoke('show-login-dialog', { url: e.url, realm: e.realm })` 调用主进程的认证对话框。
* 获取对话框结果后,使用 `e.login(username, password)` 将凭据发送给 webview。
**需要修改的文件:** `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts`
**示例代码 (渲染进程 - `useWebviewEvents.ts`):**
```typescript
// 在 setupWebviewListeners 函数内部添加
const handleShowLogin = async (e: any) => {
console.log(`[Tab ${tabId}] Show login dialog for url: ${e.url}, realm: ${e.realm}`);
e.preventDefault(); // 阻止默认行为
try {
// 调用主进程显示认证对话框
const result = await window.api.invoke('show-login-dialog', { url: e.url, realm: e.realm });
if (result && result.response === 0) { // 0 表示用户点击了登录
// 将凭据发送给webview
e.login(result.username, result.password);
} else {
// 用户取消或关闭对话框
e.cancel();
}
} catch (error) {
console.error('Failed to show login dialog:', error);
e.cancel(); // 发生错误时取消认证
}
};
// 在添加事件监听器的部分添加
webview.addEventListener('show-login', handleShowLogin);
// 在清理函数中添加移除监听器
return () => {
// ... 其他移除监听器 ...
webview.removeEventListener('show-login', handleShowLogin);
};
```
*(注意: 上述示例代码假设您已经设置了 Electron 的 Context Bridge并且在预加载脚本中将 `ipcRenderer.invoke` 暴露给了 `window.api`。如果您的 IPC 设置不同,请根据实际情况调整。)*
**预期结果:**
当访问需要 HTTP 认证的网站时,应该会弹出一个原生的登录对话框,用户输入凭据后可以进行认证。
---
## 3. 增加切换链接打开方式 (新标签页/独立窗口) 的按钮和逻辑
**问题描述:**
目前点击链接默认在新标签页打开,用户希望能够切换为在独立窗口中打开。
**实现方案:**
1. 在浏览器界面的工具栏中添加一个按钮,用于切换链接打开方式的状态。
2. 在状态管理中维护一个状态,记录当前的链接打开方式(例如 'newTab' 或 'newWindow')。
3. 修改链接点击拦截脚本和 `handleNewWindow` 函数,根据当前状态决定是调用 `openUrlInTab` 还是通过 IPC 调用主进程打开新窗口。
4. 在主进程中添加一个 IPC 处理函数,用于创建新的浏览器窗口并加载指定的 URL。
**修改步骤:**
1. **在状态管理中添加链接打开方式状态:**
* 在 `src/renderer/src/pages/Browser/hooks/useAnimatedTabs.ts` 或创建一个新的 Context 中,添加一个状态来存储当前的链接打开方式,例如 `linkOpenMode`,默认值为 `'newTab'`。
* 添加一个函数来切换这个状态,例如 `toggleLinkOpenMode`。
**需要修改的文件:** `src/renderer/src/pages/Browser/hooks/useAnimatedTabs.ts` (或新文件)
**示例代码 (useAnimatedTabs.ts):**
```typescript
// 在 useAnimatedTabs 钩子内部添加状态和切换函数
const [linkOpenMode, setLinkOpenMode] = useState<'newTab' | 'newWindow'>('newTab');
const toggleLinkOpenMode = useCallback(() => {
setLinkOpenMode(prevMode => prevMode === 'newTab' ? 'newWindow' : 'newTab');
}, []);
// 在返回的对象中暴露 linkOpenMode 和 toggleLinkOpenMode
return {
// ... 其他状态和函数 ...
linkOpenMode,
toggleLinkOpenMode,
};
```
2. **在 UI 中添加切换按钮:**
* 在浏览器工具栏组件 (`src/renderer/src/pages/Browser/components/NavBar.tsx`) 中,添加一个按钮。
* 按钮的文本或图标可以根据 `linkOpenMode` 状态变化。
* 按钮的点击事件调用 `toggleLinkOpenMode` 函数。
**需要修改的文件:** `src/renderer/src/pages/Browser/components/NavBar.tsx`
**示例代码 (NavBar.tsx):**
```typescript
// 假设 NavBar 组件接收 linkOpenMode 和 toggleLinkOpenMode 作为 props
interface NavBarProps {
// ... 其他 props ...
linkOpenMode: 'newTab' | 'newWindow';
toggleLinkOpenMode: () => void;
}
const NavBar: React.FC<NavBarProps> = ({ /* ... */ linkOpenMode, toggleLinkOpenMode }) => {
return (
<NavBarContainer>
{/* ... 其他工具栏元素 ... */}
<button onClick={toggleLinkOpenMode}>
{linkOpenMode === 'newTab' ? '新标签页模式' : '独立窗口模式'}
</button>
{/* ... 其他工具栏元素 ... */}
</NavBarContainer>
);
};
```
*(注意: 您需要将 `linkOpenMode` 和 `toggleLinkOpenMode` 从 `useAnimatedTabs` (或新 Context) 传递到 `NavBar` 组件。)*
3. **修改链接点击处理逻辑:**
* 在 `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts` 的 `handleConsoleMessage` 函数中,当处理 `LINK_CLICKED:` 消息时,根据当前的 `linkOpenMode` 状态决定是调用 `openUrlInTab` 还是触发 IPC 调用打开新窗口。
* 在 `handleNewWindow` 函数中,也需要根据 `linkOpenMode` 状态决定行为。
**需要修改的文件:** `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts`
**示例代码 (useWebviewEvents.ts - handleConsoleMessage):**
```typescript
// 在 setupWebviewListeners 函数签名中添加 linkOpenMode 参数
const setupWebviewListeners = (
// ... 其他参数 ...
linkOpenMode: 'newTab' | 'newWindow', // 添加 linkOpenMode 参数
// ... 其他参数 ...
) => {
// ...
const handleConsoleMessage = (event: any) => {
// ... (现有日志和 LINK_CLICKED 处理逻辑) ...
if (event.message && event.message.startsWith('LINK_CLICKED:')) {
try {
const dataStr = event.message.replace('LINK_CLICKED:', '')
const data = JSON.parse(dataStr)
console.log(`[Tab ${tabId}] Link clicked:`, data)
// 根据 linkOpenMode 决定打开方式
if (linkOpenMode === 'newTab' && data.url && data.inNewTab) {
console.log(`[Tab ${tabId}] Opening link in new tab:`, data.url)
openUrlInTab(data.url, true, data.title || data.url)
} else if (linkOpenMode === 'newWindow' && data.url) {
console.log(`[Tab ${tabId}] Opening link in new window:`, data.url)
// 调用主进程打开新窗口 (需要实现 IPC)
window.api.invoke('open-new-browser-window', { url: data.url, title: data.title || data.url });
} else if (data.url && !data.inNewTab) {
// 在当前标签页打开 (如果不是新标签页模式且链接没有 target="_blank")
// 这个逻辑已经在注入的脚本中处理了 window.location.href = target.href;
// 这里可以根据需要添加额外的处理或日志
console.log(`[Tab ${tabId}] Link clicked, navigating in current tab:`, data.url);
}
} catch (error) {
console.error('Failed to parse link data:', error)
}
}
// ... (保留对旧消息格式的支持) ...
}
// ...
}
```
**示例代码 (useWebviewEvents.ts - handleNewWindow):**
```typescript
// 在 setupWebviewListeners 函数签名中添加 linkOpenMode 参数 (如果尚未添加)
const setupWebviewListeners = (
// ... 其他参数 ...
linkOpenMode: 'newTab' | 'newWindow', // 确保 linkOpenMode 参数存在
// ... 其他参数 ...
) => {
// ...
// 处理新窗口打开请求
const handleNewWindow = (e: any) => {
console.log(`[Tab ${tabId}] handleNewWindow called for url: ${e.url}`)
e.preventDefault() // 阻止默认行为
console.log(`[Tab ${tabId}] New window request: ${e.url}, frameName: ${e.frameName || '未指定'}`)
// 根据 linkOpenMode 决定打开方式
if (linkOpenMode === 'newTab') {
// 始终在新标签页中打开
openUrlInTab(e.url, true, e.frameName || '加载中...')
} else if (linkOpenMode === 'newWindow') {
// 调用主进程打开新窗口 (需要实现 IPC)
window.api.invoke('open-new-browser-window', { url: e.url, title: e.frameName || e.url });
}
}
// ...
}
```
*(注意: 您需要将 `linkOpenMode` 从使用 `useAnimatedTabs` 的组件传递到 `setupWebviewListeners` 函数中。)*
4. **在主进程中添加打开新窗口的 IPC 处理:**
* 在主进程 (`src/main/index.ts` 或相关的 IPC 处理文件) 中,添加一个 IPC 监听器,例如 `ipcMain.handle('open-new-browser-window', ...)`。
* 在这个处理函数中,创建一个新的 Electron 浏览器窗口 (`new BrowserWindow(...)`) 并加载指定的 URL。
**需要修改的文件:** `src/main/index.ts` 或 IPC 处理文件
**示例代码 (主进程):**
```typescript
// src/main/index.ts 或 src/main/ipc.ts
import { ipcMain, BrowserWindow } from 'electron';
import { join } from 'path';
ipcMain.handle('open-new-browser-window', async (event, args) => {
const { url, title } = args;
// 创建新的浏览器窗口
const newWindow = new BrowserWindow({
width: 1000,
height: 800,
title: title || 'New Window',
webPreferences: {
preload: join(__dirname, '../preload/index.js'), // 根据您的项目结构调整预加载脚本路径
sandbox: false, // 根据您的安全需求调整
nodeIntegration: false, // 根据您的安全需求调整
contextIsolation: true, // 根据您的安全需求调整
},
});
// 加载URL
newWindow.loadURL(url);
// 可选: 打开开发者工具
// newWindow.webContents.openDevTools();
});
```
*(注意: 您需要根据您的项目结构调整 `preload` 路径和 `webPreferences` 设置。)*
**预期结果:**
浏览器工具栏中会出现一个切换按钮,点击可以切换链接打开方式。根据当前模式,点击链接会在新标签页或独立窗口中打开。
---
希望这份修改指南对您有帮助!如果您在修改过程中遇到任何问题,或者需要进一步的帮助,请随时告诉我。

View File

@ -1,4 +1,4 @@
const { app } = require('electron');
console.log(`Electron version: ${process.versions.electron}`);
console.log(`Chrome version: ${process.versions.chrome}`);
console.log(`Node version: ${process.versions.node}`);
const { app } = require('electron')
console.log(`Electron version: ${process.versions.electron}`)
console.log(`Chrome version: ${process.versions.chrome}`)
console.log(`Node version: ${process.versions.node}`)

View File

@ -1,4 +1,3 @@
// 这是一个启动脚本,用于加载补丁并运行 electron-builder
require('J:\Cherry\cherry-studioTTS\node_modules\app-builder-lib\out\node-module-collector\nodeModulesCollector.patch.js')
require('electron-builder/cli')

View File

@ -35,13 +35,6 @@ files:
# - '!node_modules/@tavily/core/node_modules/js-tiktoken'
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
# 排除不需要的开发工具和测试库
- '!node_modules/@vitest'
- '!node_modules/vitest'
- '!node_modules/eslint*'
- '!node_modules/prettier'
- '!node_modules/husky'
- '!node_modules/lint-staged'
asarUnpack: # Removed ASR server rules from 'files' section
- resources/**
- '**/*.{metal,exp,lib}'
@ -107,9 +100,6 @@ publish:
url: https://releases.cherry-ai.com
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
compression: maximum # 使用最大压缩以减小包体积
buildDependenciesFromSource: false # 不从源码构建依赖,加快构建速度
forceCodeSigning: false # 不强制代码签名,加快构建速度
afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js

View File

@ -48,14 +48,6 @@ export default defineConfig({
alias: {
'@shared': resolve('packages/shared')
}
},
build: {
rollupOptions: {
input: {
index: resolve('src/preload/index.ts'),
'browser-preload': resolve('src/preload/browser-preload.js')
}
}
}
},
renderer: {
@ -65,7 +57,7 @@ export default defineConfig({
[
'@swc/plugin-styled-components',
{
displayName: process.env.NODE_ENV === 'development', // 仅在开发环境下启用组件名称
displayName: true, // 开发环境下启用组件名称
fileName: false, // 不在类名中包含文件名
pure: true, // 优化性能
ssr: false // 不需要服务端渲染
@ -73,14 +65,9 @@ export default defineConfig({
]
]
}),
// 仅在非CI环境下启用Sentry插件
...(process.env.CI
? []
: [
sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN
})
]),
sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN
}),
...visualizerPlugin('renderer')
],
resolve: {
@ -89,36 +76,19 @@ export default defineConfig({
'@shared': resolve('packages/shared')
}
},
// optimizeDeps 配置已移至下方
optimizeDeps: {
exclude: []
},
build: {
rollupOptions: {
input: {
index: resolve('src/renderer/index.html')
},
// 减少打包时的警告输出
onwarn(warning, warn) {
// 忽略某些警告
if (warning.code === 'CIRCULAR_DEPENDENCY') return
warn(warning)
}
},
// 复制ASR服务器文件
assetsInlineLimit: 0,
// 确保复制assets目录下的所有文件
copyPublicDir: true,
// 启用构建缓存
commonjsOptions: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
// 提高CommonJS模块转换性能
transformMixedEsModules: true
}
},
// 启用依赖预构建缓存
optimizeDeps: {
// 强制预构建这些依赖
include: ['react', 'react-dom', 'styled-components', 'antd', 'lodash'],
// 启用缓存
disabled: false
copyPublicDir: true
}
}
})

3
install-electron.bat Normal file
View File

@ -0,0 +1,3 @@
@echo off
set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
yarn add electron@32.3.3 --dev

View File

@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "2.0.0-ludi",
"version": "1.2.6-bate",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@ -22,19 +22,16 @@
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"build:check": "yarn test && yarn typecheck && yarn check:i18n",
"fix:electron-builder": "node scripts/fix-electron-builder.js",
"update:electron": "node scripts/update-electron.js",
"update:electron:direct": "node scripts/update-electron-direct.js",
"build:unpack": "dotenv npm run build && npm run fix:electron-builder && electron-builder --dir",
"build:win": "dotenv npm run build && npm run fix:electron-builder && electron-builder --win",
"build:win:x64": "dotenv npm run build && npm run fix:electron-builder && electron-builder --win --x64",
"build:win:arm64": "dotenv npm run build && npm run fix:electron-builder && electron-builder --win --arm64",
"build:mac": "dotenv electron-vite build && npm run fix:electron-builder && electron-builder --mac",
"build:mac:arm64": "dotenv electron-vite build && npm run fix:electron-builder && electron-builder --mac --arm64",
"build:mac:x64": "dotenv electron-vite build && npm run fix:electron-builder && electron-builder --mac --x64",
"build:linux": "dotenv electron-vite build && npm run fix:electron-builder && electron-builder --linux",
"build:linux:arm64": "dotenv electron-vite build && npm run fix:electron-builder && electron-builder --linux --arm64",
"build:linux:x64": "dotenv electron-vite build && npm run fix:electron-builder && electron-builder --linux --x64",
"build:unpack": "dotenv npm run build && electron-builder --dir",
"build:win": "dotenv npm run build && electron-builder --win",
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
"build:mac": "dotenv electron-vite build && electron-builder --mac",
"build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
"build:linux": "dotenv electron-vite build && electron-builder --linux",
"build:linux:arm64": "dotenv electron-vite build && electron-builder --linux --arm64",
"build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64",
"build:npm": "node scripts/build-npm.js",
"release": "node scripts/version.js",
"publish": "yarn build:check && yarn release patch push",
@ -55,7 +52,7 @@
"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 && npm run fix:electron-builder",
"postinstall": "electron-builder install-app-deps",
"prepare": "husky"
},
"dependencies": {
@ -97,7 +94,6 @@
"@codemirror/view": "^6.36.5",
"@electron-toolkit/utils": "^3.0.0",
"@electron/notarize": "^2.5.0",
"@electron/remote": "^2.1.2",
"@google/generative-ai": "^0.24.0",
"@langchain/community": "^0.3.36",
"@langchain/core": "^0.3.44",
@ -128,14 +124,13 @@
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"fast-xml-parser": "^5.0.9",
"fetch-socks": "^1.3.2",
"framer-motion": "^12.9.2",
"fs-extra": "^11.2.0",
"glob": "^11.0.1",
"got-scraping": "^4.1.1",
"js-tiktoken": "^1.0.19",
"jsdom": "^26.0.0",
"jsxgraph": "^1.11.1",
"markdown-it": "^14.1.0",
"mathjs": "^14.4.0",
"minimatch": "^10.0.1",
"monaco-editor": "^0.52.2",
"node-edge-tts": "^1.2.8",
@ -144,21 +139,17 @@
"path-browserify": "^1.0.1",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.1.91",
"plotly.js": "^3.0.1",
"proxy-agent": "^6.5.0",
"react-syntax-highlighter": "^15.6.1",
"react-transition-group": "^4.4.5",
"react-virtualized": "^9.22.6",
"react-vtree": "^2.0.4",
"react-window": "^1.8.11",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tar": "^7.4.3",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
"undici": "^7.4.0",
"webdav": "^5.8.0",
"zipfile": "^0.5.12",
"zipread": "^1.3.3",
"zod": "^3.24.2"
},
@ -179,7 +170,7 @@
"@google/genai": "^0.8.0",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.10.1",
"@notionhq/client": "^2.2.15",
"@reduxjs/toolkit": "^2.2.5",
"@sentry/vite-plugin": "^3.3.1",
@ -217,11 +208,11 @@
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"dotenv-cli": "^7.4.2",
"electron": "35.2.0",
"electron": "32.3.3",
"electron-builder": "26.0.13",
"electron-devtools-installer": "^3.2.0",
"electron-icon-builder": "^2.0.1",
"electron-vite": "^2.3.0",
"electron-vite": "3.0.0",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
"eslint": "^9.24.0",

View File

@ -0,0 +1,39 @@
x64:
firstOrDefaultFilePatterns:
- '!**/node_modules/**'
- '!build{,/**/*}'
- '!release/2.0.0-ludi{,/**/*}'
- out
- '!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}'
- '!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}'
- '!**/node_modules/*.d.ts'
- '!**/node_modules/.bin'
- '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}'
- '!.editorconfig'
- '!**/._*'
- '!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}'
- '!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}'
- '!**/{appveyor.yml,.travis.yml,circle.yml}'
- '!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}'
- package.json
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
- '!**/._*'
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
- '!**/{.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore,.idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci,.yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log,pnpm-lock.yaml,appveyor.yml,.travis.yml,circle.yml,.nyc_output,.husky,.github,electron-builder.env}'
- '!.yarn{,/**/*}'
- '!.editorconfig'
- '!.yarnrc.yml'
nodeModuleFilePatterns:
- '**/*'
- out
- '!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}'
- '!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}'
- '!**/node_modules/*.d.ts'
- '!**/node_modules/.bin'
- '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}'
- '!.editorconfig'
- '!**/._*'
- '!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}'
- '!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}'
- '!**/{appveyor.yml,.travis.yml,circle.yml}'
- '!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}'

View File

@ -0,0 +1,30 @@
directories:
output: release/${version}
buildResources: build
npmArgs:
- '--no-pnp'
npmRebuild: true
appId: com.cherry.studio
productName: CherryStudio
asar: true
files:
- filter:
- out
- '!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}'
- '!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}'
- '!**/node_modules/*.d.ts'
- '!**/node_modules/.bin'
- '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}'
- '!.editorconfig'
- '!**/._*'
- '!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}'
- '!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}'
- '!**/{appveyor.yml,.travis.yml,circle.yml}'
- '!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}'
win:
target:
- target: portable
arch:
- x64
artifactName: ${productName}-${version}-${arch}.${ext}
electronVersion: 35.2.0

View File

@ -0,0 +1,21 @@
Copyright (c) Electron contributors
Copyright (c) 2013-2020 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
{"file_format_version": "1.0.0", "ICD": {"library_path": ".\\vk_swiftshader.dll", "api_version": "1.0.5"}}

Binary file not shown.

View File

@ -1,59 +1,104 @@
/**
* 修复 electron-builder 堆栈溢出问题的补丁脚本
*
*
* 这个脚本修复了 electron-builder 在处理循环依赖时导致的堆栈溢出问题
* 主要修改了以下文件
* 1. node_modules/app-builder-lib/out/node-module-collector/nodeModulesCollector.js
*/
const fs = require('fs');
const path = require('path');
const fs = require('fs')
const path = require('path')
// 获取 nodeModulesCollector.js 文件的路径
const nodeModulesCollectorPath = path.join(
let nodeModulesCollectorPath = path.join(
process.cwd(),
'node_modules',
'app-builder-lib',
'out',
'node-module-collector',
'nodeModulesCollector.js'
);
)
// 检查文件是否存在
console.log(`正在检查文件: ${nodeModulesCollectorPath}`)
if (!fs.existsSync(nodeModulesCollectorPath)) {
console.error('找不到 nodeModulesCollector.js 文件,请确保已安装 electron-builder');
process.exit(1);
console.error('找不到 nodeModulesCollector.js 文件,请确保已安装 electron-builder')
// 尝试查找其他可能的路径
const possiblePaths = [
path.join(
process.cwd(),
'node_modules',
'electron-builder',
'node_modules',
'app-builder-lib',
'out',
'node-module-collector',
'nodeModulesCollector.js'
),
path.join(
process.cwd(),
'node_modules',
'app-builder-lib',
'lib',
'node-module-collector',
'nodeModulesCollector.js'
),
path.join(
process.cwd(),
'node_modules',
'app-builder-lib',
'dist',
'node-module-collector',
'nodeModulesCollector.js'
)
]
for (const possiblePath of possiblePaths) {
console.log(`尝试查找: ${possiblePath}`)
if (fs.existsSync(possiblePath)) {
console.log(`找到文件: ${possiblePath}`)
nodeModulesCollectorPath = possiblePath
break
}
}
if (!fs.existsSync(nodeModulesCollectorPath)) {
console.error('无法找到 nodeModulesCollector.js 文件,请确保已安装 electron-builder')
process.exit(1)
}
}
// 读取文件内容
let content = fs.readFileSync(nodeModulesCollectorPath, 'utf8');
let content = fs.readFileSync(nodeModulesCollectorPath, 'utf8')
// 修复 1: 修改 _getNodeModules 方法,添加环路检测
const oldGetNodeModulesMethod = /(_getNodeModules\(dependencies, result\) \{[\s\S]*?result\.sort\(\(a, b\) => a\.name\.localeCompare\(b\.name\)\);\s*\})/;
const oldGetNodeModulesMethod =
/(_getNodeModules\(dependencies, result\) \{[\s\S]*?result\.sort\(\(a, b\) => a\.name\.localeCompare\(b\.name\)\);\s*\})/
const newGetNodeModulesMethod = `_getNodeModules(dependencies, result, depth = 0, visited = new Set()) {
// 添加递归深度限制
if (depth > 10) {
console.log("递归深度超过10停止递归");
return;
}
if (dependencies.size === 0) {
return;
}
for (const d of dependencies.values()) {
const reference = [...d.references][0];
const moduleId = \`\${d.name}@\${reference}\`;
// 环路检测:如果已经访问过这个模块,则跳过
if (visited.has(moduleId)) {
console.log(\`检测到循环依赖: \${moduleId}\`);
continue;
}
// 标记为已访问
visited.add(moduleId);
const p = this.dependencyPathMap.get(moduleId);
if (p === undefined) {
builder_util_1.log.debug({ name: d.name, reference }, "cannot find path for dependency");
@ -69,26 +114,27 @@ const newGetNodeModulesMethod = `_getNodeModules(dependencies, result, depth = 0
node.dependencies = [];
this._getNodeModules(d.dependencies, node.dependencies, depth + 1, visited);
}
// 处理完成后,从已访问集合中移除,允许在其他路径中再次访问
visited.delete(moduleId);
}
result.sort((a, b) => a.name.localeCompare(b.name));
}`;
}`
content = content.replace(oldGetNodeModulesMethod, newGetNodeModulesMethod);
content = content.replace(oldGetNodeModulesMethod, newGetNodeModulesMethod)
// 修复 2: 修改 getNodeModules 方法,传递 visited 集合
const oldGetNodeModulesCall = /(this\._getNodeModules\(hoisterResult\.dependencies, this\.nodeModules\);)/;
const oldGetNodeModulesCall = /(this\._getNodeModules\(hoisterResult\.dependencies, this\.nodeModules\);)/
const newGetNodeModulesCall = `// 创建一个新的 visited 集合用于环路检测
const visited = new Set();
this._getNodeModules(hoisterResult.dependencies, this.nodeModules, 0, visited);`;
content = content.replace(oldGetNodeModulesCall, newGetNodeModulesCall);
this._getNodeModules(hoisterResult.dependencies, this.nodeModules, 0, visited);`
content = content.replace(oldGetNodeModulesCall, newGetNodeModulesCall)
// 修复 3: 修改 convertToDependencyGraph 方法,跳过路径未定义的依赖
const oldPathCheck = /(if \(!dependencies\.path\) \{[\s\S]*?throw new Error\("unable to parse `path` during `tree\.dependencies` reduce"\);[\s\S]*?\})/;
const oldPathCheck =
/(if \(!dependencies\.path\) \{[\s\S]*?throw new Error\("unable to parse `path` during `tree\.dependencies` reduce"\);[\s\S]*?\})/
const newPathCheck = `if (!dependencies.path) {
builder_util_1.log.error({
packageName,
@ -99,11 +145,11 @@ const newPathCheck = `if (!dependencies.path) {
// 跳过这个依赖而不是抛出错误
console.log(\`跳过路径未定义的依赖: \${packageName}\`);
return acc;
}`;
}`
content = content.replace(oldPathCheck, newPathCheck);
content = content.replace(oldPathCheck, newPathCheck)
// 写入修改后的内容
fs.writeFileSync(nodeModulesCollectorPath, content, 'utf8');
fs.writeFileSync(nodeModulesCollectorPath, content, 'utf8')
console.log('成功应用 electron-builder 堆栈溢出修复补丁!');
console.log('成功应用 electron-builder 堆栈溢出修复补丁!')

View File

@ -33,11 +33,11 @@ if (!app.requestSingleInstanceLock()) {
// 配置浏览器会话
const browserSession = session.fromPartition('persist:browser')
// 设置用户代理
// 设置用户代理 - 使用Chrome 126的用户代理字符串但保留Chrome 134的功能
const desktopUserAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
const mobileUserAgent =
'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36'
'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.44 Mobile Safari/537.36'
// 默认使用桌面用户代理
browserSession.setUserAgent(desktopUserAgent)

View File

@ -2,6 +2,7 @@ import './services/MemoryFileService'
import fs from 'node:fs'
import { arch } from 'node:os'
import { join } from 'node:path'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
@ -293,6 +294,57 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
await shell.openPath(path)
})
// browser
ipcMain.handle('browser:openNewWindow', async (_, args: { url: string; title?: string }) => {
log.info('Received IPC call to open new window:', args) // 添加日志
const { url, title } = args
// 获取浏览器会话
const browserSession = session.fromPartition('persist:browser')
// 创建新的浏览器窗口,使用相同的会话
const newWindow = new BrowserWindow({
width: 1000,
height: 800,
title: title || 'New Window',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
nodeIntegration: false,
contextIsolation: true,
session: browserSession // 使用与内置浏览器相同的会话
}
})
// 加载URL
await newWindow.loadURL(url)
// 当窗口关闭时通知渲染进程同步cookie
newWindow.on('closed', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('browser-window-closed')
}
})
})
// 同步cookie
ipcMain.handle('browser:syncCookies', async () => {
try {
// 获取浏览器会话
const browserSession = session.fromPartition('persist:browser')
// 获取所有cookie
const cookies = await browserSession.cookies.get({})
log.info(`[Cookie Sync] Found ${cookies.length} cookies in browser session`)
return { success: true, message: `Synced ${cookies.length} cookies` }
} catch (error: any) {
log.error('[Cookie Sync] Error syncing cookies:', error)
return { success: false, message: `Error: ${error.message}` }
}
})
// shortcuts
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
configManager.setShortcuts(shortcuts)

View File

@ -19,7 +19,12 @@ export class Fetcher {
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
// 添加Chrome 126特有的请求头
'Sec-Ch-Ua': '"Chromium";v="126", "Google Chrome";v="126", "Not-A.Brand";v="99"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
...headers
}
})

View File

@ -30,7 +30,11 @@ export class SearchService {
const headers = {
...details.requestHeaders,
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
// 添加Chrome 126特有的请求头
'Sec-Ch-Ua': '"Chromium";v="126", "Google Chrome";v="126", "Not-A.Brand";v="99"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"'
}
callback({ requestHeaders: headers })
})

View File

@ -1,5 +1,6 @@
import { JSDOM } from 'jsdom'
import TurndownService from 'turndown'
import { WebSearchResult } from '../../renderer/src/types'
const turndownService = new TurndownService()
@ -81,7 +82,12 @@ export async function fetchWebContent(
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
// 添加Chrome 126特有的请求头
'Sec-Ch-Ua': '"Chromium";v="126", "Google Chrome";v="126", "Not-A.Brand";v="99"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
},
signal: controller.signal
})

View File

@ -1,160 +1,261 @@
// 浏览器预加载脚本
// 用于修改浏览器环境,绕过反爬虫检测
// 使用更真实的用户代理字符串
const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
// 控制是否启用浏览器模拟脚本
window.ENABLE_BROWSER_EMULATION = true
// 使用Chrome 126的用户代理字符串但保留Chrome 134的功能
const userAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
// 覆盖navigator.userAgent
Object.defineProperty(navigator, 'userAgent', {
value: userAgent,
writable: false
});
})
// 覆盖navigator.platform
Object.defineProperty(navigator, 'platform', {
value: 'Win32',
writable: false
});
})
// 覆盖navigator.plugins
// Chrome 126的品牌信息
const brands = [
{ brand: 'Chromium', version: '126' },
{ brand: 'Google Chrome', version: '126' },
{ brand: 'Not-A.Brand', version: '99' }
]
// 覆盖navigator.userAgentData
if (!navigator.userAgentData) {
Object.defineProperty(navigator, 'userAgentData', {
value: {
brands: brands,
mobile: false,
platform: 'Windows',
toJSON: function () {
return { brands, mobile: false, platform: 'Windows' }
}
},
writable: false
})
} else {
// 如果已经存在,则修改其属性
Object.defineProperty(navigator.userAgentData, 'brands', {
value: brands,
writable: false
})
Object.defineProperty(navigator.userAgentData, 'platform', {
value: 'Windows',
writable: false
})
}
// 覆盖navigator.plugins - Chrome 134的插件列表
Object.defineProperty(navigator, 'plugins', {
value: [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: 'Portable Document Format' },
{
name: 'Chrome PDF Viewer',
filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai',
description: 'Portable Document Format'
},
{ name: 'Native Client', filename: 'internal-nacl-plugin', description: 'Native Client' }
],
writable: false
});
})
// 覆盖navigator.languages
Object.defineProperty(navigator, 'languages', {
value: ['zh-CN', 'zh', 'en-US', 'en'],
writable: false
});
})
// 覆盖window.chrome
// 覆盖window.chrome - Chrome 134的chrome对象结构
window.chrome = {
runtime: {},
loadTimes: function() {},
csi: function() {},
app: {}
};
runtime: {
id: undefined,
connect: function () {},
sendMessage: function () {},
onMessage: { addListener: function () {} }
},
loadTimes: function () {
return {
firstPaintTime: 0,
firstPaintAfterLoadTime: 0,
requestTime: Date.now() / 1000,
startLoadTime: Date.now() / 1000,
commitLoadTime: Date.now() / 1000,
finishDocumentLoadTime: Date.now() / 1000,
finishLoadTime: Date.now() / 1000,
navigationType: 'Other'
}
},
csi: function () {
return { startE: Date.now(), onloadT: Date.now() }
},
app: { isInstalled: false },
webstore: { onInstallStageChanged: {}, onDownloadProgress: {} }
}
// 添加WebGL支持检测
// 添加WebGL支持检测 - 更新为Chrome 134的特征
try {
const origGetContext = HTMLCanvasElement.prototype.getContext;
const origGetContext = HTMLCanvasElement.prototype.getContext
if (origGetContext) {
HTMLCanvasElement.prototype.getContext = function(type, attributes) {
HTMLCanvasElement.prototype.getContext = function (type, attributes) {
if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') {
const gl = origGetContext.call(this, type, attributes);
const gl = origGetContext.call(this, type, attributes)
if (gl) {
// 修改WebGL参数以模拟真实浏览器
const getParameter = gl.getParameter.bind(gl);
gl.getParameter = function(parameter) {
// 修改WebGL参数以模拟Chrome 134
const getParameter = gl.getParameter.bind(gl)
gl.getParameter = function (parameter) {
// UNMASKED_VENDOR_WEBGL
if (parameter === 37445) {
return 'Google Inc. (NVIDIA)';
return 'Google Inc. (Intel)'
}
// UNMASKED_RENDERER_WEBGL
if (parameter === 37446) {
return 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1070 Direct3D11 vs_5_0 ps_5_0, D3D11)';
return 'ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)'
}
return getParameter(parameter);
};
// VERSION
if (parameter === 7938) {
return 'WebGL 2.0 (OpenGL ES 3.0 Chromium)'
}
// SHADING_LANGUAGE_VERSION
if (parameter === 35724) {
return 'WebGL GLSL ES 3.00 (OpenGL ES GLSL ES 3.0 Chromium)'
}
// VENDOR
if (parameter === 7936) {
return 'Google Inc.'
}
// RENDERER
if (parameter === 7937) {
return 'ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)'
}
return getParameter(parameter)
}
}
return gl;
return gl
}
return origGetContext.call(this, type, attributes);
};
return origGetContext.call(this, type, attributes)
}
}
} catch (e) {
console.error('Failed to patch WebGL:', e);
console.error('Failed to patch WebGL:', e)
}
// 添加音频上下文支持
// 添加音频上下文支持 - Chrome 134版本
try {
if (typeof AudioContext !== 'undefined') {
const origAudioContext = AudioContext;
window.AudioContext = function() {
const context = new origAudioContext();
return context;
};
const origAudioContext = AudioContext
window.AudioContext = function () {
const context = new origAudioContext()
// 模拟Chrome 134的音频上下文属性
if (context.sampleRate) {
Object.defineProperty(context, 'sampleRate', {
value: 48000,
writable: false
})
}
// 模拟音频目标节点
const origCreateMediaElementSource = context.createMediaElementSource
if (origCreateMediaElementSource) {
context.createMediaElementSource = function (mediaElement) {
const source = origCreateMediaElementSource.call(this, mediaElement)
return source
}
}
return context
}
}
} catch (e) {
console.error('Failed to patch AudioContext:', e);
console.error('Failed to patch AudioContext:', e)
}
// 添加电池API模拟
// 添加电池API模拟 - Chrome 134版本
try {
if (navigator.getBattery) {
navigator.getBattery = function() {
navigator.getBattery = function () {
return Promise.resolve({
charging: true,
chargingTime: 0,
dischargingTime: Infinity,
level: 1.0,
addEventListener: function() {},
removeEventListener: function() {}
});
};
addEventListener: function (type, listener) {
// 实现一个简单的事件监听器
if (!this._listeners) this._listeners = {}
if (!this._listeners[type]) this._listeners[type] = []
this._listeners[type].push(listener)
},
removeEventListener: function (type, listener) {
// 实现事件监听器的移除
if (!this._listeners || !this._listeners[type]) return
const index = this._listeners[type].indexOf(listener)
if (index !== -1) this._listeners[type].splice(index, 1)
}
})
}
}
} catch (e) {
console.error('Failed to patch Battery API:', e);
console.error('Failed to patch Battery API:', e)
}
// 检测Cloudflare验证码
window.addEventListener('DOMContentLoaded', () => {
try {
// 检测是否存在Cloudflare验证码或其他验证码
const hasCloudflareCaptcha = document.querySelector('iframe[src*="cloudflare"]') !== null ||
document.querySelector('.cf-browser-verification') !== null ||
document.querySelector('.cf-im-under-attack') !== null ||
document.querySelector('#challenge-form') !== null ||
document.querySelector('#challenge-running') !== null ||
document.querySelector('#challenge-error-title') !== null ||
document.querySelector('.ray-id') !== null ||
document.querySelector('.hcaptcha-box') !== null ||
document.querySelector('iframe[src*="hcaptcha"]') !== null ||
document.querySelector('iframe[src*="recaptcha"]') !== null;
// 如果存在验证码,添加一些辅助功能
if (hasCloudflareCaptcha) {
// 尝试自动点击"我是人类"复选框
const checkboxes = document.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(checkbox => {
if (checkbox.style.display !== 'none') {
checkbox.click();
}
});
// 添加一个提示,告诉用户需要手动完成验证
const notificationDiv = document.createElement('div');
notificationDiv.style.position = 'fixed';
notificationDiv.style.top = '10px';
notificationDiv.style.left = '50%';
notificationDiv.style.transform = 'translateX(-50%)';
notificationDiv.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
notificationDiv.style.color = 'white';
notificationDiv.style.padding = '10px 20px';
notificationDiv.style.borderRadius = '5px';
notificationDiv.style.zIndex = '9999999';
notificationDiv.style.fontFamily = 'Arial, sans-serif';
notificationDiv.textContent = '请完成人机验证以继续访问网站';
document.body.appendChild(notificationDiv);
// 5秒后自动隐藏提示
setTimeout(() => {
notificationDiv.style.opacity = '0';
notificationDiv.style.transition = 'opacity 1s';
setTimeout(() => {
notificationDiv.remove();
}, 1000);
}, 5000);
}
} catch (e) {
console.error('Failed to check for captcha:', e);
}
});
// 添加硬件并发层级 - Chrome 134通常报告实际CPU核心数
try {
Object.defineProperty(navigator, 'hardwareConcurrency', {
value: 8, // 设置为一个合理的值如8核
writable: false
})
} catch (e) {
console.error('Failed to patch hardwareConcurrency:', e)
}
console.log('Browser preload script loaded successfully');
// 添加设备内存 - Chrome 134会报告实际内存
try {
Object.defineProperty(navigator, 'deviceMemory', {
value: 8, // 设置为8GB
writable: false
})
} catch (e) {
console.error('Failed to patch deviceMemory:', e)
}
// 添加连接信息 - Chrome 134的NetworkInformation API
try {
if (!navigator.connection) {
Object.defineProperty(navigator, 'connection', {
value: {
effectiveType: '4g',
rtt: 50,
downlink: 10,
saveData: false,
addEventListener: function (type, listener) {
// 简单实现
if (!this._listeners) this._listeners = {}
if (!this._listeners[type]) this._listeners[type] = []
this._listeners[type].push(listener)
},
removeEventListener: function (type, listener) {
// 简单实现
if (!this._listeners || !this._listeners[type]) return
const index = this._listeners[type].indexOf(listener)
if (index !== -1) this._listeners[type].splice(index, 1)
}
},
writable: false
})
}
} catch (e) {
console.error('Failed to patch NetworkInformation API:', e)
}
// Cloudflare 验证处理已完全移除
// 测试表明不需要特殊的 CF 验证处理,移除后没有任何影响
// 如果将来需要可以参考备份文件src\renderer\src\pages\Browser\utils\cloudflareHandler.ts.bak
console.log('Browser preload script loaded successfully')

View File

@ -268,7 +268,10 @@ const api = {
},
browser: {
clearData: () => ipcRenderer.invoke('browser:clear-data'),
destroyWebContents: (webContentsId: number) => ipcRenderer.invoke('browser:destroy-webcontents', webContentsId)
destroyWebContents: (webContentsId: number) => ipcRenderer.invoke('browser:destroy-webcontents', webContentsId),
// 暴露打开新窗口的IPC通道
// 暂时使用字符串字面量绕过TypeScript错误
openNewWindow: (args: { url: string; title?: string }) => ipcRenderer.invoke('browser:openNewWindow', args)
}
}

View File

@ -23,7 +23,8 @@ import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView, highlightActiveLine, keymap, lineNumbers } from '@codemirror/view'
import { tags } from '@lezer/highlight'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { useSettings } from '@renderer/hooks/useSettings'
import { CodeStyleVarious, ThemeMode } from '@renderer/types'
import { useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import styled from 'styled-components'
@ -100,6 +101,18 @@ interface CodeMirrorEditorProps {
height?: string
}
// 获取CodeMirror主题扩展
const getThemeExtension = (codeStyle: CodeStyleVarious, isDarkMode: boolean) => {
// 目前只支持 oneDark 主题,其他主题需要安装相应的包
// 如果是暗色模式且是 auto 或特定主题,则使用 oneDark 主题
if (isDarkMode && (codeStyle === 'auto' || String(codeStyle) === 'one-dark')) {
return oneDark
}
// 其他情况返回 null使用默认主题
return null
}
const getLanguageExtension = (language: string) => {
switch (language.toLowerCase()) {
case 'javascript':
@ -161,11 +174,20 @@ const CodeMirrorEditor = ({
const editorRef = useRef<HTMLDivElement>(null)
const editorViewRef = useRef<EditorView | null>(null)
const { theme } = useTheme()
const { codeStyle } = useSettings()
// 根据当前主题选择高亮样式
// 根据当前主题和代码风格选择高亮样式
const highlightStyle = useMemo(() => {
// 如果代码风格设置为auto或未设置则根据主题选择默认样式
if (!codeStyle || codeStyle === 'auto') {
return theme === ThemeMode.dark ? darkThemeHighlightStyle : lightThemeHighlightStyle
}
// 目前仍使用默认样式因为需要为每种代码风格创建对应的CodeMirror高亮样式
// 这里可以根据codeStyle的值选择不同的高亮样式
// 未来可以扩展更多的主题支持
return theme === ThemeMode.dark ? darkThemeHighlightStyle : lightThemeHighlightStyle
}, [theme])
}, [theme, codeStyle])
// 暴露撤销/重做方法和获取内容方法
useImperativeHandle(ref, () => ({
@ -271,8 +293,9 @@ const CodeMirrorEditor = ({
}
// 添加主题
if (theme === ThemeMode.dark) {
extensions.push(oneDark)
const themeExtension = getThemeExtension(codeStyle, theme === ThemeMode.dark)
if (themeExtension) {
extensions.push(themeExtension)
}
const state = EditorState.create({
@ -290,7 +313,7 @@ const CodeMirrorEditor = ({
return () => {
view.destroy()
}
}, [code, language, onChange, readOnly, showLineNumbers, theme, fontSize, height])
}, [code, language, onChange, readOnly, showLineNumbers, theme, codeStyle, highlightStyle, fontSize, height])
return <EditorContainer ref={editorRef} />
}

View File

@ -61,7 +61,7 @@ const TTSHighlightedText: React.FC<TTSHighlightedTextProps> = ({ text }) => {
// 使用 useMemo 记忆化列表渲染结果,避免不必要的重新计算
const renderedSegments = useMemo(() => {
if (segments.length === 0) {
return <div>{text}</div>;
return <div>{text}</div>
}
return segments.map((segment, index) => (

View File

@ -1,5 +1,6 @@
import { Workspace } from '@renderer/store/workspace'
import { FileType, KnowledgeItem, QuickPhrase, Topic, TranslateHistory } from '@renderer/types'
import { Bookmark, BookmarkFolder } from '@renderer/pages/Browser/types/bookmark'
import { Dexie, type EntityTable } from 'dexie'
import { upgradeToV5 } from './upgrades'
@ -13,6 +14,8 @@ export const db = new Dexie('CherryStudio') as Dexie & {
translate_history: EntityTable<TranslateHistory, 'id'>
quick_phrases: EntityTable<QuickPhrase, 'id'>
workspaces: EntityTable<Workspace, 'id'>
bookmarks: EntityTable<Bookmark, 'id'>
bookmark_folders: EntityTable<BookmarkFolder, 'id'>
}
db.version(1).stores({
@ -69,4 +72,16 @@ db.version(7).stores({
workspaces: '&id, name, path, createdAt, updatedAt'
})
db.version(8).stores({
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id, messages',
settings: '&id, value',
knowledge_notes: '&id, baseId, type, content, created_at, updated_at',
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
quick_phrases: 'id',
workspaces: '&id, name, path, createdAt, updatedAt',
bookmarks: '&id, title, url, parentId, createdAt, updatedAt',
bookmark_folders: '&id, title, parentId, createdAt, updatedAt'
})
export default db

View File

@ -375,7 +375,9 @@
"clear_data": "清除浏览器数据",
"google_login_tip": "检测到Google登录页面建议使用移动版登录页面以获得更好的体验。",
"new_tab": "新建标签页",
"close_tab": "关闭标签页"
"close_tab": "关闭标签页",
"force_stop": "强制停止",
"stop": "停止"
},
"deepresearch": {
"title": "深度研究",

View File

@ -0,0 +1,137 @@
import { FolderOutlined } from '@ant-design/icons'
import { Form, Input, Modal, TreeSelect } from 'antd'
import React, { useCallback, useEffect, useState } from 'react'
import { useBookmarks } from '../hooks/useBookmarks'
import { BookmarkFolder } from '../types/bookmark'
interface AddBookmarkDialogProps {
visible: boolean
onClose: () => void
initialValues?: {
title: string
url: string
favicon?: string
}
onAdd: (title: string, url: string, favicon?: string, folderId?: string | null) => void
}
const AddBookmarkDialog: React.FC<AddBookmarkDialogProps> = ({
visible,
onClose,
initialValues,
onAdd
}) => {
const [form] = Form.useForm()
const { folders, loading } = useBookmarks()
const [treeData, setTreeData] = useState<any[]>([])
// 将文件夹数据转换为树形结构
useEffect(() => {
if (!folders) return
// 构建文件夹树
const buildFolderTree = (
items: BookmarkFolder[],
parentId: string | null = null
): any[] => {
return items
.filter((item) => item.parentId === parentId)
.map((item) => ({
title: item.title,
value: item.id,
key: item.id,
icon: <FolderOutlined />,
children: buildFolderTree(items, item.id)
}))
}
// 添加根目录选项
const folderTree = [
{
title: 'Bookmarks Bar',
value: null,
key: 'root',
icon: <FolderOutlined />
},
...buildFolderTree(folders)
]
setTreeData(folderTree)
}, [folders])
// 重置表单
useEffect(() => {
if (visible) {
form.resetFields()
if (initialValues) {
form.setFieldsValue({
...initialValues,
folderId: null // 默认保存到书签栏
})
}
}
}, [visible, initialValues, form])
// 处理提交
const handleSubmit = useCallback(() => {
form
.validateFields()
.then((values) => {
onAdd(values.title, values.url, values.favicon, values.folderId)
onClose()
})
.catch((info) => {
console.log('Validate Failed:', info)
})
}, [form, onAdd, onClose])
return (
<Modal
title="Add Bookmark"
open={visible}
onOk={handleSubmit}
onCancel={onClose}
okText="Add"
cancelText="Cancel"
>
<Form form={form} layout="vertical" initialValues={{ folderId: null }}>
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Please input the title!' }]}
>
<Input placeholder="Bookmark title" />
</Form.Item>
<Form.Item
name="url"
label="URL"
rules={[
{ required: true, message: 'Please input the URL!' },
{ type: 'url', message: 'Please enter a valid URL!' }
]}
>
<Input placeholder="https://example.com" />
</Form.Item>
<Form.Item name="favicon" label="Favicon URL" rules={[{ type: 'url', message: 'Please enter a valid URL!' }]}>
<Input placeholder="https://example.com/favicon.ico" />
</Form.Item>
<Form.Item name="folderId" label="Save to">
<TreeSelect
treeData={treeData}
placeholder="Select a folder"
loading={loading}
treeDefaultExpandAll
showSearch
allowClear
treeNodeFilterProp="title"
/>
</Form.Item>
</Form>
</Modal>
)
}
export default AddBookmarkDialog

View File

@ -0,0 +1,215 @@
import { PlusOutlined } from '@ant-design/icons'
import { AnimatePresence } from 'framer-motion'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
AddTabButton,
AnimatedTabsContainer,
DragPlaceholder,
TabActiveIndicator,
TabHoverIndicator,
TabsListContainer
} from '../styles/AnimatedTabsStyles'
import { Tab } from '../types'
import TabItem from './TabItem'
import { TabsProvider, useTabsContext } from './TabsContext'
interface AnimatedBrowserTabsProps {
tabs: Tab[]
activeTabId: string
onTabChange: (tabId: string) => void
onAddTab: () => void
onCloseTab: (tabId: string, e: React.MouseEvent<HTMLElement>) => void
onDragStart: (index: number) => void
onDragEnd: () => void
onDragOver: (index: number) => void
draggedTabIndex: number | null
dragOverTabIndex: number | null
animationDirection: number
}
// 内部实现组件
const AnimatedBrowserTabsInner: React.FC<AnimatedBrowserTabsProps> = ({
tabs,
activeTabId,
onTabChange,
onAddTab,
onCloseTab,
onDragStart,
onDragEnd,
onDragOver,
dragOverTabIndex
/*
// 这些参数在组件内部不直接使用,但在类型定义中需要
draggedTabIndex,
animationDirection
*/
}) => {
const { t } = useTranslation()
const { tabRefs, tabsContainerRef, hoveredTabId, draggedTabId, isDragging } = useTabsContext()
// 悬停指示器状态
const [hoverIndicator, setHoverIndicator] = useState({
x: 0,
width: 0,
opacity: 0
})
// 活动指示器状态
const [activeIndicator, setActiveIndicator] = useState({
x: 0,
width: 0
})
// 拖拽占位符状态
const [dragPlaceholder, setDragPlaceholder] = useState({
x: 0,
width: 0,
opacity: 0
})
// 更新悬停指示器
useEffect(() => {
if (hoveredTabId && tabRefs.current[hoveredTabId] && !isDragging) {
const tabElement = tabRefs.current[hoveredTabId]
const containerRect = tabsContainerRef.current?.getBoundingClientRect()
const tabRect = tabElement.getBoundingClientRect()
if (containerRect) {
setHoverIndicator({
x: tabRect.left - containerRect.left,
width: tabRect.width,
opacity: 1
})
}
} else {
setHoverIndicator((prev) => ({ ...prev, opacity: 0 }))
}
}, [hoveredTabId, tabRefs, tabsContainerRef, isDragging])
// 更新活动指示器
useEffect(() => {
if (activeTabId && tabRefs.current[activeTabId]) {
const tabElement = tabRefs.current[activeTabId]
const containerRect = tabsContainerRef.current?.getBoundingClientRect()
const tabRect = tabElement.getBoundingClientRect()
if (containerRect) {
setActiveIndicator({
x: tabRect.left - containerRect.left,
width: tabRect.width
})
}
}
}, [activeTabId, tabs, tabRefs, tabsContainerRef])
// 更新拖拽占位符
useEffect(() => {
if (draggedTabId && dragOverTabIndex !== null && tabsContainerRef.current) {
const containerRect = tabsContainerRef.current.getBoundingClientRect()
const draggedTabElement = tabRefs.current[draggedTabId]
if (draggedTabElement) {
const draggedTabRect = draggedTabElement.getBoundingClientRect()
const targetTabElement = tabRefs.current[tabs[dragOverTabIndex].id]
if (targetTabElement) {
const targetTabRect = targetTabElement.getBoundingClientRect()
setDragPlaceholder({
x: targetTabRect.left - containerRect.left,
width: draggedTabRect.width,
opacity: 0.5
})
}
}
} else {
setDragPlaceholder((prev) => ({ ...prev, opacity: 0 }))
}
}, [draggedTabId, dragOverTabIndex, tabs, tabRefs, tabsContainerRef])
return (
<AnimatedTabsContainer>
<TabsListContainer ref={tabsContainerRef}>
<AnimatePresence>
{tabs.map((tab, index) => (
<TabItem
key={tab.id}
tab={tab}
index={index}
isActive={tab.id === activeTabId}
onTabChange={onTabChange}
onCloseTab={onCloseTab}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
/>
))}
</AnimatePresence>
<AddTabButton onClick={onAddTab} title={t('browser.new_tab')}>
<PlusOutlined />
</AddTabButton>
{/* 悬停指示器 */}
<TabHoverIndicator
initial={{ opacity: 0 }}
animate={{
x: hoverIndicator.x,
width: hoverIndicator.width,
opacity: hoverIndicator.opacity
}}
transition={{
type: 'spring',
stiffness: 500,
damping: 30
}}
/>
{/* 活动指示器 */}
<TabActiveIndicator
initial={{ opacity: 0 }}
animate={{
x: activeIndicator.x,
width: activeIndicator.width,
opacity: 1
}}
transition={{
type: 'spring',
stiffness: 500,
damping: 30
}}
/>
{/* 拖拽占位符 */}
<DragPlaceholder
initial={{ opacity: 0 }}
animate={{
x: dragPlaceholder.x,
width: dragPlaceholder.width,
opacity: dragPlaceholder.opacity
}}
transition={{
type: 'spring',
stiffness: 500,
damping: 30
}}
/>
</TabsListContainer>
</AnimatedTabsContainer>
)
}
// 包装组件,提供上下文
const AnimatedBrowserTabs: React.FC<Omit<AnimatedBrowserTabsProps, 'tabRefs' | 'hoveredTabId' | 'setHoveredTabId'>> = (
props
) => {
return (
<TabsProvider>
<AnimatedBrowserTabsInner {...props} />
</TabsProvider>
)
}
export default AnimatedBrowserTabs

View File

@ -0,0 +1,163 @@
import { FolderOutlined, StarOutlined } from '@ant-design/icons'
import { Dropdown, Menu, Tooltip } from 'antd'
import React, { useCallback, useState } from 'react'
import styled from 'styled-components'
import { useBookmarks } from '../hooks/useBookmarks'
import { BookmarkTreeNode } from '../types/bookmark'
// 样式
const BookmarkBarContainer = styled.div`
display: flex;
align-items: center;
padding: 4px 8px;
background-color: var(--color-bg-1);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
white-space: nowrap;
height: 32px;
&::-webkit-scrollbar {
height: 0;
display: none;
}
`
const BookmarkItem = styled.div<{ isActive?: boolean }>`
display: flex;
align-items: center;
padding: 0 8px;
height: 24px;
border-radius: 4px;
margin-right: 4px;
cursor: pointer;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--color-text-1);
background-color: ${(props) => (props.isActive ? 'var(--color-bg-2)' : 'transparent')};
&:hover {
background-color: var(--color-bg-2);
}
.anticon {
margin-right: 4px;
font-size: 14px;
}
img.favicon {
width: 16px;
height: 16px;
margin-right: 4px;
object-fit: contain;
}
`
const BookmarkFolderItem = styled(BookmarkItem)`
color: var(--color-text-1);
font-weight: 500;
`
// 未使用的组件,保留以备将来使用
// const MoreButton = styled(Button)`
// margin-left: auto;
// padding: 0 8px;
// height: 24px;
// font-size: 12px;
// `
interface BookmarkBarProps {
onOpenUrl: (url: string, inNewTab?: boolean) => void
}
const BookmarkBar: React.FC<BookmarkBarProps> = ({ onOpenUrl }) => {
const { bookmarkTree, loading } = useBookmarks()
const [activeFolder, setActiveFolder] = useState<string | null>(null)
// 处理书签点击
const handleBookmarkClick = useCallback(
(bookmark: BookmarkTreeNode) => {
if (bookmark.url) {
console.log('Bookmark clicked:', bookmark.url)
onOpenUrl(bookmark.url, false) // 在当前标签页打开
}
},
[onOpenUrl]
)
// 渲染书签项
const renderBookmarkItem = useCallback(
(bookmark: BookmarkTreeNode) => {
if (bookmark.isFolder) {
// 渲染文件夹
return (
<Dropdown
key={bookmark.id}
overlay={
<Menu>
{bookmark.children?.map((child) => (
<Menu.Item
key={child.id}
onClick={() => handleBookmarkClick(child)}
icon={
child.isFolder ? (
<FolderOutlined />
) : child.favicon ? (
<img src={child.favicon} className="favicon" alt="" />
) : (
<StarOutlined />
)
}>
{child.title}
</Menu.Item>
))}
{(!bookmark.children || bookmark.children.length === 0) && <Menu.Item disabled>No bookmarks</Menu.Item>}
</Menu>
}
trigger={['click']}
onVisibleChange={(visible) => {
if (visible) {
setActiveFolder(bookmark.id)
} else if (activeFolder === bookmark.id) {
setActiveFolder(null)
}
}}>
<BookmarkFolderItem isActive={activeFolder === bookmark.id}>
<FolderOutlined />
<span>{bookmark.title}</span>
</BookmarkFolderItem>
</Dropdown>
)
} else {
// 渲染书签
return (
<Tooltip key={bookmark.id} title={bookmark.url}>
<BookmarkItem onClick={() => handleBookmarkClick(bookmark)}>
{bookmark.favicon ? <img src={bookmark.favicon} className="favicon" alt="" /> : <StarOutlined />}
<span>{bookmark.title}</span>
</BookmarkItem>
</Tooltip>
)
}
},
[activeFolder, handleBookmarkClick]
)
// 如果正在加载,显示加载状态
if (loading) {
return <BookmarkBarContainer>Loading bookmarks...</BookmarkBarContainer>
}
// 渲染书签栏
return (
<BookmarkBarContainer>
{bookmarkTree.map(renderBookmarkItem)}
{bookmarkTree.length === 0 && <BookmarkItem>No bookmarks yet. Add some bookmarks to see them here.</BookmarkItem>}
</BookmarkBarContainer>
)
}
export default BookmarkBar

View File

@ -0,0 +1,74 @@
import { StarFilled, StarOutlined } from '@ant-design/icons'
import { Button, Tooltip } from 'antd'
import React, { useCallback, useEffect, useState } from 'react'
import { useBookmarks } from '../hooks/useBookmarks'
import { Bookmark } from '../types/bookmark'
import AddBookmarkDialog from './AddBookmarkDialog'
interface BookmarkButtonProps {
url: string
title: string
favicon?: string
}
const BookmarkButton: React.FC<BookmarkButtonProps> = ({ url, title, favicon }) => {
const { bookmarks, addBookmark, deleteBookmark } = useBookmarks()
const [isBookmarked, setIsBookmarked] = useState(false)
const [currentBookmark, setCurrentBookmark] = useState<Bookmark | null>(null)
const [dialogVisible, setDialogVisible] = useState(false)
// 检查当前页面是否已经被收藏
useEffect(() => {
if (!url) return
const bookmark = bookmarks.find((b) => b.url === url)
setIsBookmarked(!!bookmark)
setCurrentBookmark(bookmark || null)
}, [url, bookmarks])
// 处理添加书签
const handleAddBookmark = useCallback(
async (title: string, url: string, favicon?: string, folderId?: string | null) => {
await addBookmark(title, url, favicon, folderId ?? null)
setDialogVisible(false)
},
[addBookmark]
)
// 处理删除书签
const handleRemoveBookmark = useCallback(async () => {
if (currentBookmark) {
await deleteBookmark(currentBookmark.id)
}
}, [currentBookmark, deleteBookmark])
// 处理按钮点击
const handleClick = useCallback(() => {
if (isBookmarked) {
handleRemoveBookmark()
} else {
setDialogVisible(true)
}
}, [isBookmarked, handleRemoveBookmark])
return (
<>
<Tooltip title={isBookmarked ? 'Remove from bookmarks' : 'Add to bookmarks'}>
<Button
type="text"
icon={isBookmarked ? <StarFilled style={{ color: '#faad14' }} /> : <StarOutlined />}
onClick={handleClick}
/>
</Tooltip>
<AddBookmarkDialog
visible={dialogVisible}
onClose={() => setDialogVisible(false)}
initialValues={{ title, url, favicon }}
onAdd={handleAddBookmark}
/>
</>
)
}
export default BookmarkButton

View File

@ -0,0 +1,465 @@
import {
DeleteOutlined,
EditOutlined,
ExportOutlined,
FolderAddOutlined,
FolderOutlined,
ImportOutlined,
PlusOutlined,
SearchOutlined,
StarOutlined
} from '@ant-design/icons'
import { Button, Input, message, Modal, Space, Spin, Table, Tree, Typography } from 'antd'
import React, { useCallback, useEffect, useState } from 'react'
import styled from 'styled-components'
import { useBookmarks } from '../hooks/useBookmarks'
import { Bookmark, BookmarkFolder, BookmarkTreeNode } from '../types/bookmark'
import AddBookmarkDialog from './AddBookmarkDialog'
const { Title } = Typography
// 样式
const ManagerContainer = styled.div`
display: flex;
height: 100%;
background-color: var(--color-bg-1);
`
const SidebarContainer = styled.div`
width: 250px;
padding: 16px;
border-right: 1px solid var(--color-border);
overflow-y: auto;
`
const ContentContainer = styled.div`
flex: 1;
padding: 16px;
overflow-y: auto;
`
const SearchContainer = styled.div`
margin-bottom: 16px;
`
const ActionsContainer = styled.div`
display: flex;
justify-content: space-between;
margin-bottom: 16px;
`
const LoadingContainer = styled.div`
padding: 20px;
text-align: center;
`
const FaviconImage = styled.img`
width: 16px;
height: 16px;
`
interface BookmarkManagerProps {
onOpenUrl: (url: string, inNewTab?: boolean) => void
onClose: () => void
}
const BookmarkManager: React.FC<BookmarkManagerProps> = ({ onOpenUrl, onClose }) => {
const {
bookmarks,
// folders, // 未使用,但保留注释以便将来使用
bookmarkTree,
loading,
addBookmark,
updateBookmark,
deleteBookmark,
addFolder,
updateFolder,
// deleteFolder, // 未使用,但保留注释以备将来使用
searchBookmarks,
exportBookmarks,
importBookmarks
} = useBookmarks()
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<Bookmark[]>([])
const [selectedFolder, setSelectedFolder] = useState<string | null>(null)
const [folderBookmarks, setFolderBookmarks] = useState<Bookmark[]>([])
const [addDialogVisible, setAddDialogVisible] = useState(false)
const [editBookmark, setEditBookmark] = useState<Bookmark | null>(null)
const [addFolderDialogVisible, setAddFolderDialogVisible] = useState(false)
const [editFolder, setEditFolder] = useState<BookmarkFolder | null>(null)
// 处理搜索
useEffect(() => {
const performSearch = async () => {
if (searchQuery) {
const results = await searchBookmarks(searchQuery)
setSearchResults(results)
} else {
setSearchResults([])
}
}
performSearch()
}, [searchQuery, searchBookmarks])
// 处理文件夹选择
useEffect(() => {
if (selectedFolder !== undefined) {
const folderItems = bookmarks.filter((bookmark) => bookmark.parentId === selectedFolder)
setFolderBookmarks(folderItems)
}
}, [selectedFolder, bookmarks])
// 处理添加书签
const handleAddBookmark = useCallback(
async (title: string, url: string, favicon?: string, folderId?: string | null) => {
const result = await addBookmark(title, url, favicon, folderId ?? null)
if (result) {
message.success('Bookmark added successfully')
}
},
[addBookmark]
)
// 处理编辑书签
const handleEditBookmark = useCallback(
async (title: string, url: string, favicon?: string, folderId?: string | null) => {
if (!editBookmark) return
const updates: Partial<Bookmark> = {
title,
url,
favicon,
parentId: folderId ?? null
}
const result = await updateBookmark(editBookmark.id, updates)
if (result) {
message.success('Bookmark updated successfully')
setEditBookmark(null)
}
},
[editBookmark, updateBookmark]
)
// 处理删除书签
const handleDeleteBookmark = useCallback(
async (id: string) => {
Modal.confirm({
title: 'Delete Bookmark',
content: 'Are you sure you want to delete this bookmark?',
okText: 'Yes',
okType: 'danger',
cancelText: 'No',
onOk: async () => {
const success = await deleteBookmark(id)
if (success) {
message.success('Bookmark deleted successfully')
}
}
})
},
[deleteBookmark]
)
// 处理添加文件夹
const handleAddFolder = useCallback(
async (title: string) => {
const result = await addFolder(title, selectedFolder)
if (result) {
message.success('Folder added successfully')
setAddFolderDialogVisible(false)
}
},
[addFolder, selectedFolder]
)
// 处理编辑文件夹
const handleEditFolder = useCallback(
async (title: string) => {
if (!editFolder) return
const result = await updateFolder(editFolder.id, { title })
if (result) {
message.success('Folder updated successfully')
setEditFolder(null)
}
},
[editFolder, updateFolder]
)
// 处理删除文件夹 - 目前未使用,但保留以备将来使用
// const handleDeleteFolder = useCallback(
// async (id: string) => {
// Modal.confirm({
// title: 'Delete Folder',
// content: 'Are you sure you want to delete this folder and all its contents?',
// okText: 'Yes',
// okType: 'danger',
// cancelText: 'No',
// onOk: async () => {
// const success = await deleteFolder(id)
// if (success) {
// message.success('Folder deleted successfully')
// if (selectedFolder === id) {
// setSelectedFolder(null)
// }
// }
// }
// })
// },
// [deleteFolder, selectedFolder]
// )
// 处理导出书签
const handleExportBookmarks = useCallback(async () => {
const data = await exportBookmarks()
if (data) {
const jsonString = JSON.stringify(data, null, 2)
const blob = new Blob([jsonString], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'bookmarks.json'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
message.success('Bookmarks exported successfully')
}
}, [exportBookmarks])
// 处理导入书签
const handleImportBookmarks = useCallback(() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = async (event) => {
try {
const content = event.target?.result as string
const data = JSON.parse(content)
if (data.bookmarks && data.folders) {
const success = await importBookmarks(data)
if (success) {
message.success('Bookmarks imported successfully')
}
} else {
message.error('Invalid bookmark file format')
}
} catch (err) {
console.error('Failed to import bookmarks:', err)
message.error('Failed to import bookmarks')
}
}
reader.readAsText(file)
}
input.click()
}, [importBookmarks])
// 构建树数据
const buildTreeData = useCallback((nodes: BookmarkTreeNode[]) => {
return nodes.map((node) => ({
title: node.title,
key: node.id,
icon: node.isFolder ? <FolderOutlined /> : <StarOutlined />,
isLeaf: !node.isFolder,
children: node.children ? buildTreeData(node.children) : undefined
}))
}, [])
// 处理打开书签
const handleOpenBookmark = useCallback(
(url: string) => {
console.log('Opening bookmark URL:', url)
onOpenUrl(url, false) // 在当前标签页打开
onClose() // 关闭书签管理器
},
[onOpenUrl, onClose]
)
// 表格列定义
const columns = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (text: string, record: Bookmark) => (
<Space>
{record.favicon ? <FaviconImage src={record.favicon} alt="" /> : <StarOutlined />}
<span>{text}</span>
</Space>
)
},
{
title: 'URL',
dataIndex: 'url',
key: 'url',
ellipsis: true
},
{
title: 'Actions',
key: 'actions',
width: 150,
render: (_: any, record: Bookmark) => (
<Space>
<Button type="text" icon={<EditOutlined />} onClick={() => setEditBookmark(record)} size="small" />
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleDeleteBookmark(record.id)}
size="small"
/>
<Button type="link" onClick={() => handleOpenBookmark(record.url)} size="small">
Open
</Button>
</Space>
)
}
]
return (
<ManagerContainer>
<SidebarContainer>
<Title level={4}>Bookmarks</Title>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddDialogVisible(true)} size="small">
Add
</Button>
<Button icon={<FolderAddOutlined />} onClick={() => setAddFolderDialogVisible(true)} size="small">
New Folder
</Button>
</Space>
{loading ? (
<LoadingContainer>
<Spin />
</LoadingContainer>
) : (
<Tree
treeData={buildTreeData(bookmarkTree)}
onSelect={(selectedKeys) => {
if (selectedKeys.length > 0) {
setSelectedFolder(selectedKeys[0] as string)
} else {
setSelectedFolder(null)
}
}}
selectedKeys={selectedFolder ? [selectedFolder] : []}
defaultExpandAll
showIcon
/>
)}
</SidebarContainer>
<ContentContainer>
<SearchContainer>
<Input
placeholder="Search bookmarks"
prefix={<SearchOutlined />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
allowClear
/>
</SearchContainer>
<ActionsContainer>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddDialogVisible(true)}>
Add Bookmark
</Button>
<Button icon={<FolderAddOutlined />} onClick={() => setAddFolderDialogVisible(true)}>
New Folder
</Button>
</Space>
<Space>
<Button icon={<ImportOutlined />} onClick={handleImportBookmarks}>
Import
</Button>
<Button icon={<ExportOutlined />} onClick={handleExportBookmarks}>
Export
</Button>
<Button onClick={onClose}>Close</Button>
</Space>
</ActionsContainer>
<Table
dataSource={searchQuery ? searchResults : folderBookmarks}
columns={columns}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10 }}
locale={{ emptyText: 'No bookmarks found' }}
/>
</ContentContainer>
{/* 添加书签对话框 */}
<AddBookmarkDialog
visible={addDialogVisible}
onClose={() => setAddDialogVisible(false)}
onAdd={handleAddBookmark}
/>
{/* 编辑书签对话框 */}
{editBookmark && (
<AddBookmarkDialog
visible={!!editBookmark}
onClose={() => setEditBookmark(null)}
initialValues={editBookmark}
onAdd={handleEditBookmark}
/>
)}
{/* 添加文件夹对话框 */}
<Modal
title="Add Folder"
open={addFolderDialogVisible}
onOk={() => {
const input = document.getElementById('folderNameInput') as HTMLInputElement
if (input && input.value) {
handleAddFolder(input.value)
}
}}
onCancel={() => setAddFolderDialogVisible(false)}>
<Input id="folderNameInput" placeholder="Folder name" prefix={<FolderOutlined />} autoFocus />
</Modal>
{/* 编辑文件夹对话框 */}
<Modal
title="Edit Folder"
open={!!editFolder}
onOk={() => {
const input = document.getElementById('editFolderNameInput') as HTMLInputElement
if (input && input.value) {
handleEditFolder(input.value)
}
}}
onCancel={() => setEditFolder(null)}>
<Input
id="editFolderNameInput"
placeholder="Folder name"
prefix={<FolderOutlined />}
defaultValue={editFolder?.title}
autoFocus
/>
</Modal>
</ManagerContainer>
)
}
export default BookmarkManager

View File

@ -0,0 +1,52 @@
import { CloseOutlined, PlusOutlined } from '@ant-design/icons'
import { Button, Tabs } from 'antd'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { TabsContainer } from '../styles/BrowserStyles'
import { FaviconImage } from '../styles/BrowserStyles'
import { Tab } from '../types'
interface BrowserTabsProps {
tabs: Tab[]
activeTabId: string
onTabChange: (tabId: string) => void
onAddTab: () => void
onCloseTab: (tabId: string, e: React.MouseEvent<HTMLElement>) => void
}
const BrowserTabs: React.FC<BrowserTabsProps> = ({ tabs, activeTabId, onTabChange, onAddTab, onCloseTab }) => {
const { t } = useTranslation()
return (
<TabsContainer>
<Tabs
type="card"
activeKey={activeTabId}
onChange={onTabChange}
tabBarExtraContent={{
right: (
<Button
className="add-tab-button"
icon={<PlusOutlined />}
onClick={onAddTab}
title={t('browser.new_tab')}
/>
)
}}
items={tabs.map((tab) => ({
key: tab.id,
label: (
<span>
{tab.favicon && <FaviconImage src={tab.favicon} alt="" />}
{tab.title || tab.url}
<CloseOutlined onClick={(e) => onCloseTab(tab.id, e)} />
</span>
)
}))}
/>
</TabsContainer>
)
}
export default BrowserTabs

Some files were not shown because too many files have changed in this diff Show More