修复升级到32.3.3
This commit is contained in:
+9
-3
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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` 设置。)*
|
||||
|
||||
**预期结果:**
|
||||
浏览器工具栏中会出现一个切换按钮,点击可以切换链接打开方式。根据当前模式,点击链接会在新标签页或独立窗口中打开。
|
||||
|
||||
---
|
||||
|
||||
希望这份修改指南对您有帮助!如果您在修改过程中遇到任何问题,或者需要进一步的帮助,请随时告诉我。
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-38
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
yarn add electron@32.3.3 --dev
|
||||
+16
-25
@@ -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",
|
||||
|
||||
@@ -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}'
|
||||
@@ -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
|
||||
@@ -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.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"file_format_version": "1.0.0", "ICD": {"library_path": ".\\vk_swiftshader.dll", "api_version": "1.0.5"}}
|
||||
Binary file not shown.
@@ -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 堆栈溢出修复补丁!')
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
+199
-98
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -375,7 +375,9 @@
|
||||
"clear_data": "清除浏览器数据",
|
||||
"google_login_tip": "检测到Google登录页面,建议使用移动版登录页面以获得更好的体验。",
|
||||
"new_tab": "新建标签页",
|
||||
"close_tab": "关闭标签页"
|
||||
"close_tab": "关闭标签页",
|
||||
"force_stop": "强制停止",
|
||||
"stop": "停止"
|
||||
},
|
||||
"deepresearch": {
|
||||
"title": "深度研究",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user