Merge branch 'main' into feat/aisdk-package

This commit is contained in:
one 2025-08-17 16:11:18 +08:00
commit 239c849890
68 changed files with 1962 additions and 552 deletions

View File

@ -5,7 +5,7 @@ import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileMetadata, PreprocessProvider } from '@types'
import AdmZip from 'adm-zip'
import axios, { AxiosRequestConfig } from 'axios'
import { net } from 'electron'
import BasePreprocessProvider from './BasePreprocessProvider'
@ -38,19 +38,24 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
}
private async validateFile(filePath: string): Promise<void> {
const pdfBuffer = await fs.promises.readFile(filePath)
// 首先检查文件大小,避免读取大文件到内存
const stats = await fs.promises.stat(filePath)
const fileSizeBytes = stats.size
// 文件大小小于300MB
if (fileSizeBytes >= 300 * 1024 * 1024) {
const fileSizeMB = Math.round(fileSizeBytes / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 300MB`)
}
// 只有在文件大小合理的情况下才读取文件内容检查页数
const pdfBuffer = await fs.promises.readFile(filePath)
const doc = await this.readPdf(pdfBuffer)
// 文件页数小于1000页
if (doc.numPages >= 1000) {
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 1000 pages`)
}
// 文件大小小于300MB
if (pdfBuffer.length >= 300 * 1024 * 1024) {
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 300MB`)
}
}
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
@ -160,11 +165,23 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
* @returns url和uid
*/
private async preupload(): Promise<PreuploadResponse> {
const config = this.createAuthConfig()
const endpoint = `${this.provider.apiHost}/api/v2/parse/preupload`
try {
const { data } = await axios.post<ApiResponse<PreuploadResponse>>(endpoint, null, config)
const response = await net.fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.provider.apiKey}`
},
body: null
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = (await response.json()) as ApiResponse<PreuploadResponse>
if (data.code === 'success' && data.data) {
return data.data
@ -178,17 +195,29 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
}
/**
*
* 使
* @param filePath
* @param url url
*/
private async putFile(filePath: string, url: string): Promise<void> {
try {
const fileStream = fs.createReadStream(filePath)
const response = await axios.put(url, fileStream)
// 获取文件大小用于设置 Content-Length
const stats = await fs.promises.stat(filePath)
const fileSize = stats.size
if (response.status !== 200) {
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
// 创建可读流
const fileStream = fs.createReadStream(filePath)
const response = await net.fetch(url, {
method: 'PUT',
body: fileStream as any, // TypeScript 类型转换net.fetch 支持 ReadableStream
headers: {
'Content-Length': fileSize.toString()
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
} catch (error) {
logger.error(`Failed to upload file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
@ -197,16 +226,25 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
}
private async getStatus(uid: string): Promise<StatusResponse> {
const config = this.createAuthConfig()
const endpoint = `${this.provider.apiHost}/api/v2/parse/status?uid=${uid}`
try {
const response = await axios.get<ApiResponse<StatusResponse>>(endpoint, config)
const response = await net.fetch(endpoint, {
method: 'GET',
headers: {
Authorization: `Bearer ${this.provider.apiKey}`
}
})
if (response.data.code === 'success' && response.data.data) {
return response.data.data
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = (await response.json()) as ApiResponse<StatusResponse>
if (data.code === 'success' && data.data) {
return data.data
} else {
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`)
}
} catch (error) {
logger.error(`Failed to get status for uid ${uid}: ${error instanceof Error ? error.message : String(error)}`)
@ -221,13 +259,6 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
*/
private async convertFile(uid: string, filePath: string): Promise<void> {
const fileName = path.parse(filePath).name
const config = {
...this.createAuthConfig(),
headers: {
...this.createAuthConfig().headers,
'Content-Type': 'application/json'
}
}
const payload = {
uid,
@ -239,10 +270,22 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
const endpoint = `${this.provider.apiHost}/api/v2/convert/parse`
try {
const response = await axios.post<ApiResponse<any>>(endpoint, payload, config)
const response = await net.fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.provider.apiKey}`
},
body: JSON.stringify(payload)
})
if (response.data.code !== 'success') {
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = (await response.json()) as ApiResponse<any>
if (data.code !== 'success') {
throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`)
}
} catch (error) {
logger.error(`Failed to convert file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
@ -256,16 +299,25 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
* @returns
*/
private async getParsedFile(uid: string): Promise<ParsedFileResponse> {
const config = this.createAuthConfig()
const endpoint = `${this.provider.apiHost}/api/v2/convert/parse/result?uid=${uid}`
try {
const response = await axios.get<ApiResponse<ParsedFileResponse>>(endpoint, config)
const response = await net.fetch(endpoint, {
method: 'GET',
headers: {
Authorization: `Bearer ${this.provider.apiKey}`
}
})
if (response.status === 200 && response.data.data) {
return response.data.data
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = (await response.json()) as ApiResponse<ParsedFileResponse>
if (data.data) {
return data.data
} else {
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
throw new Error(`No data in response`)
}
} catch (error) {
logger.error(
@ -295,8 +347,12 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
try {
// 下载文件
const response = await axios.get(url, { responseType: 'arraybuffer' })
fs.writeFileSync(zipPath, response.data)
const response = await net.fetch(url, { method: 'GET' })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const arrayBuffer = await response.arrayBuffer()
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
// 确保提取目录存在
if (!fs.existsSync(extractPath)) {
@ -318,14 +374,6 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
}
}
private createAuthConfig(): AxiosRequestConfig {
return {
headers: {
Authorization: `Bearer ${this.provider.apiKey}`
}
}
}
public checkQuota(): Promise<number> {
throw new Error('Method not implemented.')
}

View File

@ -5,7 +5,7 @@ import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileMetadata, PreprocessProvider } from '@types'
import AdmZip from 'adm-zip'
import axios from 'axios'
import { net } from 'electron'
import BasePreprocessProvider from './BasePreprocessProvider'
@ -95,7 +95,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
public async checkQuota() {
try {
const quota = await fetch(`${this.provider.apiHost}/api/v4/quota`, {
const quota = await net.fetch(`${this.provider.apiHost}/api/v4/quota`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@ -179,8 +179,12 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
try {
// 下载ZIP文件
const response = await axios.get(zipUrl, { responseType: 'arraybuffer' })
fs.writeFileSync(zipPath, Buffer.from(response.data))
const response = await net.fetch(zipUrl, { method: 'GET' })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const arrayBuffer = await response.arrayBuffer()
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
logger.info(`Downloaded ZIP file: ${zipPath}`)
// 确保提取目录存在
@ -236,7 +240,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
}
try {
const response = await fetch(endpoint, {
const response = await net.fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -271,7 +275,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
try {
const fileBuffer = await fs.promises.readFile(filePath)
const response = await fetch(uploadUrl, {
const response = await net.fetch(uploadUrl, {
method: 'PUT',
body: fileBuffer,
headers: {
@ -316,7 +320,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
const endpoint = `${this.provider.apiHost}/api/v4/extract-results/batch/${batchId}`
try {
const response = await fetch(endpoint, {
const response = await net.fetch(endpoint, {
method: 'GET',
headers: {
'Content-Type': 'application/json',

View File

@ -1,6 +1,6 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import { net } from 'electron'
import BaseReranker from './BaseReranker'
@ -15,7 +15,17 @@ export default class GeneralReranker extends BaseReranker {
const requestBody = this.getRerankRequestBody(query, searchResults)
try {
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
const response = await net.fetch(url, {
method: 'POST',
headers: this.defaultHeaders(),
body: JSON.stringify(requestBody)
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
const rerankResults = this.extractRerankResult(data)
return this.getRerankResult(searchResults, rerankResults)

View File

@ -3,6 +3,7 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
import { net } from 'electron'
const WEB_SEARCH_TOOL: Tool = {
name: 'brave_web_search',
@ -159,7 +160,7 @@ async function performWebSearch(apiKey: string, query: string, count: number = 1
url.searchParams.set('count', Math.min(count, 20).toString()) // API limit
url.searchParams.set('offset', offset.toString())
const response = await fetch(url, {
const response = await net.fetch(url.toString(), {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
@ -192,7 +193,7 @@ async function performLocalSearch(apiKey: string, query: string, count: number =
webUrl.searchParams.set('result_filter', 'locations')
webUrl.searchParams.set('count', Math.min(count, 20).toString())
const webResponse = await fetch(webUrl, {
const webResponse = await net.fetch(webUrl.toString(), {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
@ -225,7 +226,7 @@ async function getPoisData(apiKey: string, ids: string[]): Promise<BravePoiRespo
checkRateLimit()
const url = new URL('https://api.search.brave.com/res/v1/local/pois')
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
const response = await fetch(url, {
const response = await net.fetch(url.toString(), {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
@ -244,7 +245,7 @@ async function getDescriptionsData(apiKey: string, ids: string[]): Promise<Brave
checkRateLimit()
const url = new URL('https://api.search.brave.com/res/v1/local/descriptions')
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
const response = await fetch(url, {
const response = await net.fetch(url.toString(), {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',

View File

@ -2,6 +2,7 @@
import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { net } from 'electron'
import * as z from 'zod/v4'
const logger = loggerService.withContext('DifyKnowledgeServer')
@ -134,7 +135,7 @@ class DifyKnowledgeServer {
private async performListKnowledges(difyKey: string, apiHost: string): Promise<McpResponse> {
try {
const url = `${apiHost.replace(/\/$/, '')}/datasets`
const response = await fetch(url, {
const response = await net.fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${difyKey}`
@ -186,7 +187,7 @@ class DifyKnowledgeServer {
try {
const url = `${apiHost.replace(/\/$/, '')}/datasets/${id}/retrieve`
const response = await fetch(url, {
const response = await net.fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${difyKey}`,

View File

@ -2,6 +2,7 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { net } from 'electron'
import { JSDOM } from 'jsdom'
import TurndownService from 'turndown'
import { z } from 'zod'
@ -16,7 +17,7 @@ export type RequestPayload = z.infer<typeof RequestPayloadSchema>
export class Fetcher {
private static async _fetch({ url, headers }: RequestPayload): Promise<Response> {
try {
const response = await fetch(url, {
const response = await net.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',

View File

@ -6,7 +6,7 @@ import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
import { app, BrowserWindow, dialog, net } from 'electron'
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import semver from 'semver'
@ -75,7 +75,7 @@ export default class AppUpdater {
}
try {
logger.info(`get release version from github: ${channel}`)
const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
const responses = await net.fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
headers
})
const data = (await responses.json()) as GithubReleaseInfo[]
@ -99,7 +99,7 @@ export default class AppUpdater {
if (mightHaveLatest) {
logger.info(`might have latest release, get latest release`)
const latestReleaseResponse = await fetch(
const latestReleaseResponse = await net.fetch(
'https://api.github.com/repos/CherryHQ/cherry-studio/releases/latest',
{
headers

View File

@ -1,6 +1,5 @@
import { loggerService } from '@logger'
import { AxiosRequestConfig } from 'axios'
import axios from 'axios'
import { net } from 'electron'
import { app, safeStorage } from 'electron'
import fs from 'fs/promises'
import path from 'path'
@ -86,7 +85,8 @@ class CopilotService {
*/
public getUser = async (_: Electron.IpcMainInvokeEvent, token: string): Promise<UserResponse> => {
try {
const config: AxiosRequestConfig = {
const response = await net.fetch(CONFIG.API_URLS.GITHUB_USER, {
method: 'GET',
headers: {
Connection: 'keep-alive',
'user-agent': 'Visual Studio Code (desktop)',
@ -95,12 +95,16 @@ class CopilotService {
'Sec-Fetch-Dest': 'empty',
authorization: `token ${token}`
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const response = await axios.get(CONFIG.API_URLS.GITHUB_USER, config)
const data = await response.json()
return {
login: response.data.login,
avatar: response.data.avatar_url
login: data.login,
avatar: data.avatar_url
}
} catch (error) {
logger.error('Failed to get user information:', error as Error)
@ -118,16 +122,23 @@ class CopilotService {
try {
this.updateHeaders(headers)
const response = await axios.post<AuthResponse>(
CONFIG.API_URLS.GITHUB_DEVICE_CODE,
{
const response = await net.fetch(CONFIG.API_URLS.GITHUB_DEVICE_CODE, {
method: 'POST',
headers: {
...this.headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_id: CONFIG.GITHUB_CLIENT_ID,
scope: 'read:user'
},
{ headers: this.headers }
)
})
})
return response.data
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return (await response.json()) as AuthResponse
} catch (error) {
logger.error('Failed to get auth message:', error as Error)
throw new CopilotServiceError('无法获取GitHub授权信息', error)
@ -150,17 +161,25 @@ class CopilotService {
await this.delay(currentDelay)
try {
const response = await axios.post<TokenResponse>(
CONFIG.API_URLS.GITHUB_ACCESS_TOKEN,
{
const response = await net.fetch(CONFIG.API_URLS.GITHUB_ACCESS_TOKEN, {
method: 'POST',
headers: {
...this.headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_id: CONFIG.GITHUB_CLIENT_ID,
device_code,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
},
{ headers: this.headers }
)
})
})
const { access_token } = response.data
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = (await response.json()) as TokenResponse
const { access_token } = data
if (access_token) {
return { access_token }
}
@ -205,16 +224,19 @@ class CopilotService {
const encryptedToken = await fs.readFile(this.tokenFilePath)
const access_token = safeStorage.decryptString(Buffer.from(encryptedToken))
const config: AxiosRequestConfig = {
const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, {
method: 'GET',
headers: {
...this.headers,
authorization: `token ${access_token}`
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const response = await axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
return response.data
return (await response.json()) as CopilotTokenResponse
} catch (error) {
logger.error('Failed to get Copilot token:', error as Error)
throw new CopilotServiceError('无法获取Copilot令牌请重新授权', error)

View File

@ -5,6 +5,7 @@ import { FileMetadata } from '@types'
import * as crypto from 'crypto'
import {
dialog,
net,
OpenDialogOptions,
OpenDialogReturnValue,
SaveDialogOptions,
@ -509,7 +510,7 @@ class FileStorage {
isUseContentType?: boolean
): Promise<FileMetadata> => {
try {
const response = await fetch(url)
const response = await net.fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}

View File

@ -29,7 +29,7 @@ import {
} from '@modelcontextprotocol/sdk/types.js'
import { nanoid } from '@reduxjs/toolkit'
import type { GetResourceResponse, MCPCallToolResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@types'
import { app } from 'electron'
import { app, net } from 'electron'
import { EventEmitter } from 'events'
import { memoize } from 'lodash'
import { v4 as uuidv4 } from 'uuid'
@ -205,7 +205,7 @@ class McpService {
}
}
return fetch(url, { ...init, headers })
return net.fetch(typeof url === 'string' ? url : url.toString(), { ...init, headers })
}
},
requestInit: {

View File

@ -2,6 +2,7 @@ import path from 'node:path'
import { loggerService } from '@logger'
import { NUTSTORE_HOST } from '@shared/config/nutstore'
import { net } from 'electron'
import { XMLParser } from 'fast-xml-parser'
import { isNil, partial } from 'lodash'
import { type FileStat } from 'webdav'
@ -62,7 +63,7 @@ export async function getDirectoryContents(token: string, target: string): Promi
let currentUrl = `${NUTSTORE_HOST}${target}`
while (true) {
const response = await fetch(currentUrl, {
const response = await net.fetch(currentUrl, {
method: 'PROPFIND',
headers: {
Authorization: `Basic ${token}`,

View File

@ -1,4 +1,5 @@
import { loggerService } from '@logger'
import { net } from 'electron'
const logger = loggerService.withContext('IpService')
@ -12,7 +13,7 @@ export async function getIpCountry(): Promise<string> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const ipinfo = await fetch('https://ipinfo.io/json', {
const ipinfo = await net.fetch('https://ipinfo.io/json', {
signal: controller.signal,
headers: {
'User-Agent':

View File

@ -6,7 +6,7 @@
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio</title>
<title>Cherry Studio Quick Assistant</title>
<style>
html,

View File

@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AihubmixAPIClient } from '../AihubmixAPIClient'
import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient'
import { ApiClientFactory } from '../ApiClientFactory'
import { AwsBedrockAPIClient } from '../aws/AwsBedrockAPIClient'
import { GeminiAPIClient } from '../gemini/GeminiAPIClient'
import { VertexAPIClient } from '../gemini/VertexAPIClient'
import { NewAPIClient } from '../NewAPIClient'
@ -54,6 +55,19 @@ vi.mock('../openai/OpenAIResponseAPIClient', () => ({
vi.mock('../ppio/PPIOAPIClient', () => ({
PPIOAPIClient: vi.fn().mockImplementation(() => ({}))
}))
vi.mock('../aws/AwsBedrockAPIClient', () => ({
AwsBedrockAPIClient: vi.fn().mockImplementation(() => ({}))
}))
// Mock the models config to prevent circular dependency issues
vi.mock('@renderer/config/models', () => ({
findTokenLimit: vi.fn(),
isReasoningModel: vi.fn(),
SYSTEM_MODELS: {
silicon: [],
defaultModel: []
}
}))
describe('ApiClientFactory', () => {
beforeEach(() => {
@ -144,6 +158,15 @@ describe('ApiClientFactory', () => {
expect(client).toBeDefined()
})
it('should create AwsBedrockAPIClient for aws-bedrock type', () => {
const provider = createTestProvider('aws-bedrock', 'aws-bedrock')
const client = ApiClientFactory.create(provider)
expect(AwsBedrockAPIClient).toHaveBeenCalledWith(provider)
expect(client).toBeDefined()
})
// 测试默认情况
it('should create OpenAIAPIClient as default for unknown type', () => {
const provider = createTestProvider('unknown', 'unknown-type')

View File

@ -2,19 +2,23 @@ import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesComman
import {
BedrockRuntimeClient,
ConverseCommand,
ConverseStreamCommand,
InvokeModelCommand
InvokeModelCommand,
InvokeModelWithResponseStreamCommand
} from '@aws-sdk/client-bedrock-runtime'
import { loggerService } from '@logger'
import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import { findTokenLimit, isReasoningModel } from '@renderer/config/models'
import {
getAwsBedrockAccessKeyId,
getAwsBedrockRegion,
getAwsBedrockSecretAccessKey
} from '@renderer/hooks/useAwsBedrock'
import { getAssistantSettings } from '@renderer/services/AssistantService'
import { estimateTextTokens } from '@renderer/services/TokenService'
import {
Assistant,
EFFORT_RATIO,
GenerateImageParams,
MCPCallToolResponse,
MCPTool,
@ -23,7 +27,13 @@ import {
Provider,
ToolCallResponse
} from '@renderer/types'
import { ChunkType, MCPToolCreatedChunk, TextDeltaChunk } from '@renderer/types/chunk'
import {
ChunkType,
MCPToolCreatedChunk,
TextDeltaChunk,
ThinkingDeltaChunk,
ThinkingStartChunk
} from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
AwsBedrockSdkInstance,
@ -33,6 +43,7 @@ import {
AwsBedrockSdkRawOutput,
AwsBedrockSdkTool,
AwsBedrockSdkToolCall,
AwsBedrockStreamChunk,
SdkModel
} from '@renderer/types/sdk'
import { convertBase64ImageToAwsBedrockFormat } from '@renderer/utils/aws-bedrock-utils'
@ -103,46 +114,65 @@ export class AwsBedrockAPIClient extends BaseApiClient<
override async createCompletions(payload: AwsBedrockSdkParams): Promise<AwsBedrockSdkRawOutput> {
const sdk = await this.getSdkInstance()
// 转换消息格式到AWS SDK原生格式
// 转换消息格式(用于 InvokeModelWithResponseStreamCommand
const awsMessages = payload.messages.map((msg) => ({
role: msg.role,
content: msg.content.map((content) => {
if (content.text) {
return { text: content.text }
return { type: 'text', text: content.text }
}
if (content.image) {
// 处理图片数据,将 Uint8Array 或数字数组转换为 base64 字符串
let base64Data = ''
if (content.image.source.bytes) {
if (typeof content.image.source.bytes === 'string') {
// 如果已经是字符串,直接使用
base64Data = content.image.source.bytes
} else {
// 如果是数组或 Uint8Array转换为 base64
const uint8Array = new Uint8Array(Object.values(content.image.source.bytes))
const binaryString = Array.from(uint8Array)
.map((byte) => String.fromCharCode(byte))
.join('')
base64Data = btoa(binaryString)
}
}
return {
image: {
format: content.image.format,
source: content.image.source
type: 'image',
source: {
type: 'base64',
media_type: `image/${content.image.format}`,
data: base64Data
}
}
}
if (content.toolResult) {
return {
toolResult: {
toolUseId: content.toolResult.toolUseId,
content: content.toolResult.content,
status: content.toolResult.status
}
type: 'tool_result',
tool_use_id: content.toolResult.toolUseId,
content: content.toolResult.content
}
}
if (content.toolUse) {
return {
toolUse: {
toolUseId: content.toolUse.toolUseId,
name: content.toolUse.name,
input: content.toolUse.input
}
type: 'tool_use',
id: content.toolUse.toolUseId,
name: content.toolUse.name,
input: content.toolUse.input
}
}
// 返回符合AWS SDK ContentBlock类型的对象
return { text: 'Unknown content type' }
return { type: 'text', text: 'Unknown content type' }
})
}))
logger.info('Creating completions with model ID:', { modelId: payload.modelId })
const excludeKeys = ['modelId', 'messages', 'system', 'maxTokens', 'temperature', 'topP', 'stream', 'tools']
const additionalParams = Object.keys(payload)
.filter((key) => !excludeKeys.includes(key))
.reduce((acc, key) => ({ ...acc, [key]: payload[key] }), {})
const commonParams = {
modelId: payload.modelId,
messages: awsMessages as any,
@ -162,10 +192,18 @@ export class AwsBedrockAPIClient extends BaseApiClient<
try {
if (payload.stream) {
const command = new ConverseStreamCommand(commonParams)
// 根据模型类型选择正确的 API 格式
const requestBody = this.createRequestBodyForModel(commonParams, additionalParams)
const command = new InvokeModelWithResponseStreamCommand({
modelId: commonParams.modelId,
body: JSON.stringify(requestBody),
contentType: 'application/json',
accept: 'application/json'
})
const response = await sdk.client.send(command)
// 直接返回AWS Bedrock流式响应的异步迭代器
return this.createStreamIterator(response)
return this.createInvokeModelStreamIterator(response)
} else {
const command = new ConverseCommand(commonParams)
const response = await sdk.client.send(command)
@ -177,32 +215,236 @@ export class AwsBedrockAPIClient extends BaseApiClient<
}
}
private async *createStreamIterator(response: any): AsyncIterable<AwsBedrockSdkRawChunk> {
try {
if (response.stream) {
for await (const chunk of response.stream) {
logger.debug('AWS Bedrock chunk received:', chunk)
/**
*
*/
private createRequestBodyForModel(commonParams: any, additionalParams: any): any {
const modelId = commonParams.modelId.toLowerCase()
// AWS Bedrock的流式响应格式转换为标准格式
if (chunk.contentBlockDelta?.delta?.text) {
yield {
contentBlockDelta: {
delta: { text: chunk.contentBlockDelta.delta.text }
// Claude 系列模型使用 Anthropic API 格式
if (modelId.includes('claude')) {
return {
anthropic_version: 'bedrock-2023-05-31',
max_tokens: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
top_p: commonParams.inferenceConfig.topP,
messages: commonParams.messages,
...(commonParams.system && commonParams.system[0]?.text ? { system: commonParams.system[0].text } : {}),
...(commonParams.toolConfig?.tools ? { tools: commonParams.toolConfig.tools } : {}),
...additionalParams
}
}
// OpenAI 系列模型
if (modelId.includes('gpt') || modelId.includes('openai')) {
const messages: any[] = []
// 添加系统消息
if (commonParams.system && commonParams.system[0]?.text) {
messages.push({
role: 'system',
content: commonParams.system[0].text
})
}
// 转换消息格式
for (const message of commonParams.messages) {
const content: any[] = []
for (const part of message.content) {
if (part.text) {
content.push({ type: 'text', text: part.text })
} else if (part.image) {
content.push({
type: 'image_url',
image_url: {
url: `data:image/${part.image.format};base64,${part.image.source.bytes}`
}
})
}
}
messages.push({
role: message.role,
content: content.length === 1 && content[0].type === 'text' ? content[0].text : content
})
}
const baseBody: any = {
model: commonParams.modelId,
messages: messages,
max_tokens: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
top_p: commonParams.inferenceConfig.topP,
stream: true,
...(commonParams.toolConfig?.tools ? { tools: commonParams.toolConfig.tools } : {})
}
// OpenAI 模型的 thinking 参数格式
if (additionalParams.reasoning_effort) {
baseBody.reasoning_effort = additionalParams.reasoning_effort
delete additionalParams.reasoning_effort
}
return {
...baseBody,
...additionalParams
}
}
// Llama 系列模型
if (modelId.includes('llama')) {
const baseBody: any = {
prompt: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
max_gen_len: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
top_p: commonParams.inferenceConfig.topP
}
// Llama 模型的 thinking 参数格式
if (additionalParams.thinking_mode) {
baseBody.thinking_mode = additionalParams.thinking_mode
delete additionalParams.thinking_mode
}
return {
...baseBody,
...additionalParams
}
}
// Amazon Titan 系列模型
if (modelId.includes('titan')) {
const textGenerationConfig: any = {
maxTokenCount: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
topP: commonParams.inferenceConfig.topP
}
// 将 thinking 相关参数添加到 textGenerationConfig 中
if (additionalParams.thinking) {
textGenerationConfig.thinking = additionalParams.thinking
delete additionalParams.thinking
}
return {
inputText: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
textGenerationConfig: {
...textGenerationConfig,
...Object.keys(additionalParams).reduce((acc, key) => {
if (['thinking_tokens', 'reasoning_mode'].includes(key)) {
acc[key] = additionalParams[key]
delete additionalParams[key]
}
return acc
}, {} as any)
},
...additionalParams
}
}
// Cohere Command 系列模型
if (modelId.includes('cohere') || modelId.includes('command')) {
const baseBody: any = {
message: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
max_tokens: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
p: commonParams.inferenceConfig.topP
}
// Cohere 模型的 thinking 参数格式
if (additionalParams.thinking) {
baseBody.thinking = additionalParams.thinking
delete additionalParams.thinking
}
if (additionalParams.reasoning_tokens) {
baseBody.reasoning_tokens = additionalParams.reasoning_tokens
delete additionalParams.reasoning_tokens
}
return {
...baseBody,
...additionalParams
}
}
// 默认使用通用格式
const baseBody: any = {
prompt: this.convertMessagesToPrompt(commonParams.messages, commonParams.system),
max_tokens: commonParams.inferenceConfig.maxTokens,
temperature: commonParams.inferenceConfig.temperature,
top_p: commonParams.inferenceConfig.topP
}
return {
...baseBody,
...additionalParams
}
}
/**
* prompt
*/
private convertMessagesToPrompt(messages: any[], system?: any[]): string {
let prompt = ''
// 添加系统消息
if (system && system[0]?.text) {
prompt += `System: ${system[0].text}\n\n`
}
// 添加对话消息
for (const message of messages) {
const role = message.role === 'assistant' ? 'Assistant' : 'Human'
let content = ''
for (const part of message.content) {
if (part.text) {
content += part.text
} else if (part.image) {
content += '[Image]'
}
}
prompt += `${role}: ${content}\n\n`
}
prompt += 'Assistant:'
return prompt
}
private async *createInvokeModelStreamIterator(response: any): AsyncIterable<AwsBedrockSdkRawChunk> {
try {
if (response.body) {
for await (const event of response.body) {
if (event.chunk) {
const chunk: AwsBedrockStreamChunk = JSON.parse(new TextDecoder().decode(event.chunk.bytes))
// 转换为标准格式
if (chunk.type === 'content_block_delta') {
yield {
contentBlockDelta: {
delta: chunk.delta,
contentBlockIndex: chunk.index
}
}
} else if (chunk.type === 'message_start') {
yield { messageStart: chunk }
} else if (chunk.type === 'message_stop') {
yield { messageStop: chunk }
} else if (chunk.type === 'content_block_start') {
yield {
contentBlockStart: {
start: chunk.content_block,
contentBlockIndex: chunk.index
}
}
} else if (chunk.type === 'content_block_stop') {
yield {
contentBlockStop: {
contentBlockIndex: chunk.index
}
}
}
}
if (chunk.messageStart) {
yield { messageStart: chunk.messageStart }
}
if (chunk.messageStop) {
yield { messageStop: chunk.messageStop }
}
if (chunk.metadata) {
yield { metadata: chunk.metadata }
}
}
}
} catch (error) {
@ -485,6 +727,38 @@ export class AwsBedrockAPIClient extends BaseApiClient<
}
}
// 获取推理预算token对所有支持推理的模型
const budgetTokens = this.getBudgetToken(assistant, model)
// 构建基础自定义参数
const customParams: Record<string, any> =
coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {}
// 根据模型类型添加 thinking 参数
if (budgetTokens) {
const modelId = model.id.toLowerCase()
if (modelId.includes('claude')) {
// Claude 模型使用 Anthropic 格式
customParams.thinking = { type: 'enabled', budget_tokens: budgetTokens }
} else if (modelId.includes('gpt') || modelId.includes('openai')) {
// OpenAI 模型格式
customParams.reasoning_effort = assistant?.settings?.reasoning_effort
} else if (modelId.includes('llama')) {
// Llama 模型格式
customParams.thinking_mode = true
customParams.thinking_tokens = budgetTokens
} else if (modelId.includes('titan')) {
// Titan 模型格式
customParams.thinking = { enabled: true }
customParams.thinking_tokens = budgetTokens
} else if (modelId.includes('cohere') || modelId.includes('command')) {
// Cohere 模型格式
customParams.thinking = { enabled: true }
customParams.reasoning_tokens = budgetTokens
}
}
const payload: AwsBedrockSdkParams = {
modelId: model.id,
messages:
@ -497,9 +771,7 @@ export class AwsBedrockAPIClient extends BaseApiClient<
topP: this.getTopP(assistant, model),
stream: streamOutput !== false,
tools: tools.length > 0 ? tools : undefined,
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
// 注意:用户自定义参数总是应该覆盖其他参数
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
...customParams
}
const timeout = this.getTimeout(model)
@ -511,6 +783,7 @@ export class AwsBedrockAPIClient extends BaseApiClient<
getResponseChunkTransformer(): ResponseChunkTransformer<AwsBedrockSdkRawChunk> {
return () => {
let hasStartedText = false
let hasStartedThinking = false
let accumulatedJson = ''
const toolCalls: Record<number, AwsBedrockSdkToolCall> = {}
@ -570,6 +843,24 @@ export class AwsBedrockAPIClient extends BaseApiClient<
} as TextDeltaChunk)
}
// 处理thinking增量
if (
rawChunk.contentBlockDelta?.delta?.type === 'thinking_delta' &&
rawChunk.contentBlockDelta?.delta?.thinking
) {
if (!hasStartedThinking) {
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
hasStartedThinking = true
}
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: rawChunk.contentBlockDelta.delta.thinking
} as ThinkingDeltaChunk)
}
// 处理内容块停止事件 - 参考 Anthropic 的 content_block_stop 处理
if (rawChunk.contentBlockStop) {
const blockIndex = rawChunk.contentBlockStop.contentBlockIndex || 0
@ -708,4 +999,49 @@ export class AwsBedrockAPIClient extends BaseApiClient<
extractMessagesFromSdkPayload(sdkPayload: AwsBedrockSdkParams): AwsBedrockSdkMessageParam[] {
return sdkPayload.messages || []
}
/**
* AWS Bedrock token
* @param assistant - The assistant
* @param model - The model
* @returns The budget tokens for reasoning effort
*/
private getBudgetToken(assistant: Assistant, model: Model): number | undefined {
try {
if (!isReasoningModel(model)) {
return undefined
}
const { maxTokens } = getAssistantSettings(assistant)
const reasoningEffort = assistant?.settings?.reasoning_effort
if (reasoningEffort === undefined) {
return undefined
}
const effortRatio = EFFORT_RATIO[reasoningEffort]
const tokenLimits = findTokenLimit(model.id)
if (tokenLimits) {
// 使用模型特定的 token 限制
const budgetTokens = Math.max(
1024,
Math.floor(
Math.min(
(tokenLimits.max - tokenLimits.min) * effortRatio + tokenLimits.min,
(maxTokens || DEFAULT_MAX_TOKENS) * effortRatio
)
)
)
return budgetTokens
} else {
// 对于没有特定限制的模型,使用简化计算
const budgetTokens = Math.max(1024, Math.floor((maxTokens || DEFAULT_MAX_TOKENS) * effortRatio))
return budgetTokens
}
} catch (error) {
logger.warn('Failed to calculate budget tokens for reasoning effort:', error as Error)
return undefined
}
}
}

View File

@ -9,6 +9,7 @@ import {
isGPT5SeriesModel,
isGrokReasoningModel,
isNotSupportSystemMessageModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
isQwenMTModel,
isQwenReasoningModel,
@ -146,7 +147,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
return {}
}
// Don't disable reasoning for models that require it
if (isGrokReasoningModel(model)) {
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
@ -524,12 +525,13 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
// 1. 处理系统消息
let systemMessage = { role: 'system', content: assistant.prompt || '' }
const systemMessage = { role: 'system', content: assistant.prompt || '' }
if (isSupportedReasoningEffortOpenAIModel(model)) {
systemMessage = {
role: isSupportDeveloperRoleProvider(this.provider) ? 'developer' : 'system',
content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}`
if (isSupportDeveloperRoleProvider(this.provider)) {
systemMessage.role = 'developer'
} else {
systemMessage.role = 'system'
}
}

View File

@ -130,7 +130,7 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
mouseEnterDelay={0.5}
placement="top"
// 确保不留下明文
destroyTooltipOnHide>
destroyOnHidden>
<span style={{ cursor: 'help' }}>{maskApiKey(keyStatus.key)}</span>
</Tooltip>

View File

@ -1,9 +1,9 @@
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
import React, { memo, useCallback } from 'react'
import styled from 'styled-components'
import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowWhiteContainer } from './styles'
import { BasicPreviewHandles, BasicPreviewProps } from './types'
import { renderSvgInShadowHost } from './utils'
@ -13,8 +13,10 @@ const vizInitializer = new AsyncInitializer(async () => {
return await module.instance()
})
/** Graphviz
* 使 usePreviewRenderer hook
/**
* Graphviz
* - 使 useDebouncedRender
* - 使 shadow dom SVG
*/
const GraphvizPreview = ({
children,
@ -41,16 +43,9 @@ const GraphvizPreview = ({
ref={ref}
imageRef={containerRef}
source="graphviz">
<StyledGraphviz ref={containerRef} className="graphviz special-preview" />
<ShadowWhiteContainer ref={containerRef} className="graphviz special-preview" />
</ImagePreviewLayout>
)
}
const StyledGraphviz = styled.div`
overflow: auto;
position: relative;
width: 100%;
height: 100%;
`
export default memo(GraphvizPreview)

View File

@ -1,15 +1,17 @@
import { nanoid } from '@reduxjs/toolkit'
import { useMermaid } from '@renderer/hooks/useMermaid'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowTransparentContainer } from './styles'
import { BasicPreviewHandles, BasicPreviewProps } from './types'
import { renderSvgInShadowHost } from './utils'
/** Mermaid
* 使 usePreviewRenderer hook
* FIXME: 等将来 mermaid-js
/**
* Mermaid
* - 使 useDebouncedRender
* - 使 shadow dom SVG
*/
const MermaidPreview = ({
children,
@ -20,17 +22,39 @@ const MermaidPreview = ({
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
const [isVisible, setIsVisible] = useState(true)
// 定义渲染函数
/**
* shadow dom
* 退 innerHTML
*/
const renderMermaid = useCallback(
async (content: string, container: HTMLDivElement) => {
// 验证语法,提前抛出异常
await mermaid.parse(content)
const { svg } = await mermaid.render(diagramId, content, container)
// 获取容器宽度
const { width } = container.getBoundingClientRect()
if (width === 0) return
// 避免不可见时产生 undefined 和 NaN
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
container.innerHTML = fixedSvg
// 创建临时的 div 用于 mermaid 测量
const measureEl = document.createElement('div')
measureEl.style.position = 'absolute'
measureEl.style.left = '-9999px'
measureEl.style.top = '-9999px'
measureEl.style.width = `${width}px`
document.body.appendChild(measureEl)
try {
const { svg } = await mermaid.render(diagramId, content, measureEl)
// 避免不可见时产生 undefined 和 NaN
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
// 有问题可以回退到 innerHTML
renderSvgInShadowHost(fixedSvg, container)
// container.innerHTML = fixedSvg
} finally {
document.body.removeChild(measureEl)
}
},
[diagramId, mermaid]
)
@ -63,7 +87,7 @@ const MermaidPreview = ({
const element = containerRef.current
if (!element) return
const currentlyVisible = element.offsetParent !== null
const currentlyVisible = element.offsetParent !== null && element.offsetWidth > 0 && element.offsetHeight > 0
setIsVisible(currentlyVisible)
}
@ -105,16 +129,9 @@ const MermaidPreview = ({
ref={ref}
imageRef={containerRef}
source="mermaid">
<StyledMermaid ref={containerRef} className="mermaid special-preview" />
<ShadowTransparentContainer ref={containerRef} className="mermaid special-preview" />
</ImagePreviewLayout>
)
}
const StyledMermaid = styled.div`
overflow: auto;
position: relative;
width: 100%;
height: 100%;
`
export default memo(MermaidPreview)

View File

@ -4,6 +4,7 @@ import React, { memo, useCallback, useEffect } from 'react'
import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowWhiteContainer } from './styles'
import { BasicPreviewHandles, BasicPreviewProps } from './types'
import { renderSvgInShadowHost } from './utils'
@ -128,7 +129,7 @@ const PlantUmlPreview = ({
ref={ref}
imageRef={containerRef}
source="plantuml">
<div ref={containerRef} className="plantuml-preview special-preview" />
<ShadowWhiteContainer ref={containerRef} className="plantuml-preview special-preview" />
</ImagePreviewLayout>
)
}

View File

@ -2,6 +2,7 @@ import { memo, useCallback } from 'react'
import { useDebouncedRender } from './hooks/useDebouncedRender'
import ImagePreviewLayout from './ImagePreviewLayout'
import { ShadowWhiteContainer } from './styles'
import { BasicPreviewHandles } from './types'
import { renderSvgInShadowHost } from './utils'
@ -34,7 +35,7 @@ const SvgPreview = ({ children, enableToolbar = false, className, ref }: SvgPrev
ref={ref}
imageRef={containerRef}
source="svg">
<div ref={containerRef} className={className ?? 'svg-preview special-preview'}></div>
<ShadowWhiteContainer ref={containerRef} className={className ?? 'svg-preview special-preview'} />
</ImagePreviewLayout>
)
}

View File

@ -2,10 +2,9 @@
exports[`GraphvizPreview > basic rendering > should match snapshot 1`] = `
.c0 {
overflow: auto;
position: relative;
width: 100%;
height: 100%;
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
}
<div>

View File

@ -2,10 +2,9 @@
exports[`MermaidPreview > basic rendering > should match snapshot 1`] = `
.c0 {
overflow: auto;
position: relative;
width: 100%;
height: 100%;
--shadow-host-background-color: transparent;
--shadow-host-border: unset;
--shadow-host-border-radius: unset;
}
<div>

View File

@ -1,6 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = `
.c0 {
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
}
<div>
<div
data-source="plantuml"
@ -15,7 +21,7 @@ exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = `
data-testid="preview-content"
>
<div
class="plantuml-preview special-preview"
class="c0 plantuml-preview special-preview"
/>
</div>
</div>

View File

@ -1,6 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
.c0 {
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
}
<div>
<div
data-source="svg"
@ -15,7 +21,7 @@ exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
data-testid="preview-content"
>
<div
class="svg-preview special-preview"
class="c0 svg-preview special-preview"
/>
</div>
</div>

View File

@ -10,29 +10,39 @@ describe('renderSvgInShadowHost', () => {
// Mock attachShadow
Element.prototype.attachShadow = vi.fn().mockImplementation(function (this: HTMLElement) {
const shadowRoot = document.createElement('div')
// Check if a shadow root already exists to prevent re-creating it.
if (this.shadowRoot) {
return this.shadowRoot
}
// Create a container that acts as the shadow root.
const shadowRootContainer = document.createElement('div')
shadowRootContainer.dataset.testid = 'shadow-root'
Object.defineProperty(this, 'shadowRoot', {
value: shadowRoot,
value: shadowRootContainer,
writable: true,
configurable: true
})
// Simple innerHTML copy for test verification
Object.defineProperty(shadowRoot, 'innerHTML', {
set(value) {
shadowRoot.textContent = value // A simplified mock
// Mock essential methods like appendChild and innerHTML.
// JSDOM doesn't fully implement shadow DOM, so we simulate its behavior.
const originalInnerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')
Object.defineProperty(shadowRootContainer, 'innerHTML', {
set(value: string) {
// Clear existing content and parse the new HTML.
originalInnerHTMLDescriptor?.set?.call(this, '')
const template = document.createElement('template')
template.innerHTML = value
shadowRootContainer.append(...Array.from(template.content.childNodes))
},
get() {
return shadowRoot.textContent || ''
return originalInnerHTMLDescriptor?.get?.call(this) ?? ''
},
configurable: true
})
shadowRoot.appendChild = vi.fn(<T extends Node>(node: T): T => {
shadowRoot.append(node)
return node
})
return shadowRoot as unknown as ShadowRoot
return shadowRootContainer as unknown as ShadowRoot
})
})
@ -57,7 +67,7 @@ describe('renderSvgInShadowHost', () => {
expect(Element.prototype.attachShadow).not.toHaveBeenCalled()
// Verify it works with the existing shadow root
expect(existingShadowRoot.appendChild).toHaveBeenCalled()
expect(existingShadowRoot.innerHTML).toContain('<svg')
})
it('should inject styles and valid SVG content into the shadow DOM', () => {
@ -71,20 +81,31 @@ describe('renderSvgInShadowHost', () => {
expect(shadowRoot?.querySelector('rect')).not.toBeNull()
})
it('should add the xmlns attribute if it is missing', () => {
const svgWithoutXmlns = '<svg width="100" height="100"><circle cx="50" cy="50" r="40" /></svg>'
renderSvgInShadowHost(svgWithoutXmlns, hostElement)
const svgElement = hostElement.shadowRoot?.querySelector('svg')
expect(svgElement).not.toBeNull()
expect(svgElement?.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg')
})
it('should throw an error if the host element is not available', () => {
expect(() => renderSvgInShadowHost('<svg></svg>', null as any)).toThrow(
'Host element for SVG rendering is not available.'
)
})
it('should throw an error for invalid SVG content', () => {
const invalidSvg = '<svg><rect></svg>' // Malformed
expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).toThrow(/SVG parsing error/)
it('should not throw an error for malformed SVG content due to HTML parser fallback', () => {
const invalidSvg = '<svg><rect></svg>' // Malformed, but fixable by the browser's HTML parser
expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).not.toThrow()
// Also, assert that it successfully rendered something.
expect(hostElement.shadowRoot?.querySelector('svg')).not.toBeNull()
})
it('should throw an error for non-SVG content', () => {
const nonSvg = '<div>this is not svg</div>'
expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow('Invalid SVG content')
expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow()
})
it('should not throw an error for empty or whitespace content', () => {

View File

@ -33,3 +33,15 @@ export const PreviewContainer = styled(Flex).attrs({ role: 'alert' })`
}
}
`
export const ShadowWhiteContainer = styled.div`
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
`
export const ShadowTransparentContainer = styled.div`
--shadow-host-background-color: transparent;
--shadow-host-border: unset;
--shadow-host-border-radius: unset;
`

View File

@ -1,3 +1,6 @@
import { makeSvgSizeAdaptive } from '@renderer/utils'
import DOMPurify from 'dompurify'
/**
* Renders an SVG string inside a host element's Shadow DOM to ensure style encapsulation.
* This function handles creating the shadow root, injecting base styles for the host,
@ -12,50 +15,78 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
throw new Error('Host element for SVG rendering is not available.')
}
// Sanitize the SVG content
const sanitizedContent = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
RETURN_DOM_FRAGMENT: false,
RETURN_DOM: false
})
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
// Base styles for the host element
// Base styles for the host element and the inner SVG
const style = document.createElement('style')
style.textContent = `
:host {
--shadow-host-background-color: white;
--shadow-host-border: 0.5px solid var(--color-code-background);
--shadow-host-border-radius: 8px;
background-color: var(--shadow-host-background-color);
border: var(--shadow-host-border);
border-radius: var(--shadow-host-border-radius);
padding: 1em;
background-color: white;
overflow: auto;
border: 0.5px solid var(--color-code-background);
border-radius: 8px;
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
display: block;
position: relative;
width: 100%;
height: 100%;
}
svg {
max-width: 100%;
height: auto;
}
`
// Clear previous content and append new style and SVG
// Clear previous content and append new style
shadowRoot.innerHTML = ''
shadowRoot.appendChild(style)
// Parse and append the SVG using DOMParser to prevent script execution and check for errors
if (svgContent.trim() === '') {
if (sanitizedContent.trim() === '') {
return
}
const parser = new DOMParser()
const doc = parser.parseFromString(svgContent, 'image/svg+xml')
const parser = new DOMParser()
const doc = parser.parseFromString(sanitizedContent, 'image/svg+xml')
const parserError = doc.querySelector('parsererror')
if (parserError) {
// Throw a specific error that can be caught by the calling component
throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
let svgElement: Element = doc.documentElement
// If parsing fails or the namespace is incorrect, fall back to the more lenient HTML parser.
if (parserError || svgElement.namespaceURI !== 'http://www.w3.org/2000/svg') {
const tempDiv = document.createElement('div')
tempDiv.innerHTML = sanitizedContent
const svgFromHtml = tempDiv.querySelector('svg')
if (svgFromHtml) {
// Directly use the DOM node created by the HTML parser.
svgElement = svgFromHtml
// Ensure the xmlns attribute is present.
svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
} else {
// If both parsing methods fail, the SVG content is genuinely invalid.
if (parserError) {
throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
}
throw new Error('Invalid SVG content: The provided string does not contain a valid SVG element.')
}
}
const svgElement = doc.documentElement
if (svgElement && svgElement.nodeName.toLowerCase() === 'svg') {
shadowRoot.appendChild(svgElement.cloneNode(true))
} else if (svgContent.trim() !== '') {
// Do not throw error for empty content
// Type guard
if (svgElement instanceof SVGSVGElement) {
// Standardize the SVG element for proper scaling
makeSvgSizeAdaptive(svgElement)
// Append the SVG element to the shadow root
shadowRoot.appendChild(svgElement)
} else {
// This path is taken if the content is valid XML but not a valid SVG document
// (e.g., root element is not <svg>), or if the fallback parser fails.
throw new Error('Invalid SVG content: The provided string is not a valid SVG document.')
}
}

View File

@ -5,7 +5,8 @@ import {
QuickPanelCloseAction,
QuickPanelContextType,
QuickPanelListItem,
QuickPanelOpenOptions
QuickPanelOpenOptions,
QuickPanelTriggerInfo
} from './types'
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
@ -19,9 +20,8 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const [defaultIndex, setDefaultIndex] = useState<number>(0)
const [pageSize, setPageSize] = useState<number>(7)
const [multiple, setMultiple] = useState<boolean>(false)
const [onClose, setOnClose] = useState<
((Options: Pick<QuickPanelCallBackOptions, 'symbol' | 'action'>) => void) | undefined
>()
const [triggerInfo, setTriggerInfo] = useState<QuickPanelTriggerInfo | undefined>()
const [onClose, setOnClose] = useState<((Options: Partial<QuickPanelCallBackOptions>) => void) | undefined>()
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
@ -44,6 +44,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
setPageSize(options.pageSize ?? 7)
setMultiple(options.multiple ?? false)
setSymbol(options.symbol)
setTriggerInfo(options.triggerInfo)
setOnClose(() => options.onClose)
setBeforeAction(() => options.beforeAction)
@ -53,9 +54,9 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
}, [])
const close = useCallback(
(action?: QuickPanelCloseAction) => {
(action?: QuickPanelCloseAction, searchText?: string) => {
setIsVisible(false)
onClose?.({ symbol, action })
onClose?.({ symbol, action, triggerInfo, searchText, item: {} as QuickPanelListItem, multiple: false })
clearTimer.current = setTimeout(() => {
setList([])
@ -64,9 +65,10 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
setAfterAction(undefined)
setTitle(undefined)
setSymbol('')
setTriggerInfo(undefined)
}, 200)
},
[onClose, symbol]
[onClose, symbol, triggerInfo]
)
useEffect(() => {
@ -92,6 +94,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
triggerInfo,
onClose,
beforeAction,
afterAction
@ -107,6 +110,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
triggerInfo,
onClose,
beforeAction,
afterAction

View File

@ -1,6 +1,12 @@
import React from 'react'
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
export type QuickPanelTriggerInfo = {
type: 'input' | 'button'
position?: number
originalText?: string
}
export type QuickPanelCallBackOptions = {
symbol: string
action: QuickPanelCloseAction
@ -8,6 +14,7 @@ export type QuickPanelCallBackOptions = {
searchText?: string
/** 是否处于多选状态 */
multiple?: boolean
triggerInfo?: QuickPanelTriggerInfo
}
export type QuickPanelOpenOptions = {
@ -26,6 +33,8 @@ export type QuickPanelOpenOptions = {
* /@#
*/
symbol: string
/** 触发信息,记录面板是如何被打开的 */
triggerInfo?: QuickPanelTriggerInfo
beforeAction?: (options: QuickPanelCallBackOptions) => void
afterAction?: (options: QuickPanelCallBackOptions) => void
onClose?: (options: QuickPanelCallBackOptions) => void
@ -51,7 +60,7 @@ export type QuickPanelListItem = {
// 定义上下文类型
export interface QuickPanelContextType {
readonly open: (options: QuickPanelOpenOptions) => void
readonly close: (action?: QuickPanelCloseAction) => void
readonly close: (action?: QuickPanelCloseAction, searchText?: string) => void
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
readonly isVisible: boolean
readonly symbol: string
@ -60,6 +69,7 @@ export interface QuickPanelContextType {
readonly defaultIndex: number
readonly pageSize: number
readonly multiple: boolean
readonly triggerInfo?: QuickPanelTriggerInfo
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void

View File

@ -204,7 +204,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const handleClose = useCallback(
(action?: QuickPanelCloseAction) => {
ctx.close(action)
// 传递 searchText 给 close 函数,去掉第一个字符(@ 或 /
const cleanSearchText = searchText.length > 1 ? searchText.slice(1) : ''
ctx.close(action, cleanSearchText)
setHistoryPanel([])
scrollTriggerRef.current = 'initial'
@ -217,7 +219,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
clearSearchText(true)
}
},
[ctx, clearSearchText, setInputText]
[ctx, clearSearchText, setInputText, searchText]
)
const handleItemAction = useCallback(

View File

@ -292,6 +292,7 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
// 模型类型到支持的reasoning_effort的映射表
export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
default: ['low', 'medium', 'high'] as const,
o: ['low', 'medium', 'high'] as const,
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
grok: ['low', 'high'] as const,
gemini: ['low', 'medium', 'high', 'auto'] as const,
@ -307,7 +308,8 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
// 模型类型到支持选项的映射表
export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
gpt5: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
@ -320,28 +322,28 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
} as const
export const getThinkModelType = (model: Model): ThinkingModelType => {
let thinkingModelType: ThinkingModelType = 'default'
if (isGPT5SeriesModel(model)) {
return 'gpt5'
}
if (isSupportedThinkingTokenGeminiModel(model)) {
thinkingModelType = 'gpt5'
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
thinkingModelType = 'o'
} else if (isSupportedThinkingTokenGeminiModel(model)) {
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
return 'gemini'
thinkingModelType = 'gemini'
} else {
return 'gemini_pro'
thinkingModelType = 'gemini_pro'
}
}
if (isSupportedReasoningEffortGrokModel(model)) return 'grok'
if (isSupportedThinkingTokenQwenModel(model)) {
} else if (isSupportedReasoningEffortGrokModel(model)) thinkingModelType = 'grok'
else if (isSupportedThinkingTokenQwenModel(model)) {
if (isQwenAlwaysThinkModel(model)) {
return 'qwen_thinking'
thinkingModelType = 'qwen_thinking'
}
return 'qwen'
}
if (isSupportedThinkingTokenDoubaoModel(model)) return 'doubao'
if (isSupportedThinkingTokenHunyuanModel(model)) return 'hunyuan'
if (isSupportedReasoningEffortPerplexityModel(model)) return 'perplexity'
if (isSupportedThinkingTokenZhipuModel(model)) return 'zhipu'
return 'default'
thinkingModelType = 'qwen'
} else if (isSupportedThinkingTokenDoubaoModel(model)) thinkingModelType = 'doubao'
else if (isSupportedThinkingTokenHunyuanModel(model)) thinkingModelType = 'hunyuan'
else if (isSupportedReasoningEffortPerplexityModel(model)) thinkingModelType = 'perplexity'
else if (isSupportedThinkingTokenZhipuModel(model)) thinkingModelType = 'zhipu'
return thinkingModelType
}
export function isFunctionCallingModel(model?: Model): boolean {
@ -636,35 +638,59 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
}
],
aihubmix: [
{
id: 'gpt-5',
provider: 'aihubmix',
name: 'gpt-5',
group: 'OpenAI'
},
{
id: 'gpt-5-mini',
provider: 'aihubmix',
name: 'gpt-5-mini',
group: 'OpenAI'
},
{
id: 'gpt-5-nano',
provider: 'aihubmix',
name: 'gpt-5-nano',
group: 'OpenAI'
},
{
id: 'gpt-5-chat-latest',
provider: 'aihubmix',
name: 'gpt-5-chat-latest',
group: 'OpenAI'
},
{
id: 'o3',
provider: 'aihubmix',
name: 'o3',
group: 'gpt'
group: 'OpenAI'
},
{
id: 'o4-mini',
provider: 'aihubmix',
name: 'o4-mini',
group: 'gpt'
group: 'OpenAI'
},
{
id: 'gpt-4.1',
provider: 'aihubmix',
name: 'gpt-4.1',
group: 'gpt'
group: 'OpenAI'
},
{
id: 'gpt-4o',
provider: 'aihubmix',
name: 'gpt-4o',
group: 'gpt'
group: 'OpenAI'
},
{
id: 'gpt-image-1',
provider: 'aihubmix',
name: 'gpt-image-1',
group: 'gpt'
group: 'OpenAI'
},
{
id: 'DeepSeek-V3',
@ -672,29 +698,59 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
name: 'DeepSeek-V3',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1',
provider: 'aihubmix',
name: 'DeepSeek-R1',
group: 'DeepSeek'
},
{
id: 'claude-sonnet-4-20250514',
provider: 'aihubmix',
name: 'claude-sonnet-4-20250514',
group: 'claude'
group: 'Claude'
},
{
id: 'gemini-2.5-pro-preview-05-06',
id: 'gemini-2.5-pro',
provider: 'aihubmix',
name: 'gemini-2.5-pro-preview-05-06',
group: 'gemini'
name: 'gemini-2.5-pro',
group: 'Gemini'
},
{
id: 'gemini-2.5-flash-preview-05-20-nothink',
id: 'gemini-2.5-flash-nothink',
provider: 'aihubmix',
name: 'gemini-2.5-flash-preview-05-20-nothink',
group: 'gemini'
name: 'gemini-2.5-flash-nothink',
group: 'Gemini'
},
{
id: 'gemini-2.5-flash',
provider: 'aihubmix',
name: 'gemini-2.5-flash',
group: 'gemini'
group: 'Gemini'
},
{
id: 'Qwen3-235B-A22B-Instruct-2507',
provider: 'aihubmix',
name: 'Qwen3-235B-A22B-Instruct-2507',
group: 'qwen'
},
{
id: 'kimi-k2-0711-preview',
provider: 'aihubmix',
name: 'kimi-k2-0711-preview',
group: 'moonshot'
},
{
id: 'Llama-4-Scout-17B-16E-Instruct',
provider: 'aihubmix',
name: 'Llama-4-Scout-17B-16E-Instruct',
group: 'llama'
},
{
id: 'Llama-4-Maverick-17B-128E-Instruct-FP8',
provider: 'aihubmix',
name: 'Llama-4-Maverick-17B-128E-Instruct-FP8',
group: 'llama'
}
],
@ -2897,12 +2953,16 @@ export function isWebSearchModel(model: Model): boolean {
}
if (provider.id === 'aihubmix') {
// modelId 不以-search结尾
if (!modelId.endsWith('-search') && GEMINI_SEARCH_REGEX.test(modelId)) {
return true
}
if (isOpenAIWebSearchModel(model)) {
return true
}
const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search']
return models.includes(modelId)
return false
}
if (provider?.type === 'openai') {

View File

@ -1,4 +1,5 @@
import { RootState } from '@renderer/store'
import { syncPreprocessProvider as _syncPreprocessProvider } from '@renderer/store/knowledge'
import {
setDefaultPreprocessProvider as _setDefaultPreprocessProvider,
updatePreprocessProvider as _updatePreprocessProvider,
@ -17,7 +18,14 @@ export const usePreprocessProvider = (id: PreprocessProviderId) => {
return {
provider,
updateProvider: (updates: Partial<PreprocessProvider>) => dispatch(_updatePreprocessProvider({ id, ...updates }))
updateProvider: (updates: Partial<PreprocessProvider>) => {
const payload = { id, ...updates }
dispatch(_updatePreprocessProvider(payload))
// 将更新同步到所有知识库中的引用
if (updates.apiHost || updates.apiKey || updates.model) {
dispatch(_syncPreprocessProvider(payload))
}
}
}
}

View File

@ -840,6 +840,9 @@
"created": "Created",
"last_updated": "Last Updated",
"messages": "Messages",
"notion": {
"reasoning_truncated": "Chain of thought cannot be chunked and has been truncated."
},
"user": "User"
},
"files": {
@ -1257,7 +1260,8 @@
},
"notion": {
"export": "Failed to export to Notion. Please check connection status and configuration according to documentation",
"no_api_key": "Notion ApiKey or Notion DatabaseID is not configured"
"no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
"no_content": "There is nothing to export to Notion."
},
"siyuan": {
"export": "Failed to export to Siyuan Note, please check connection status and configuration according to documentation",
@ -1383,14 +1387,8 @@
}
},
"warn": {
"notion": {
"exporting": "Exporting to Notion, please do not request export repeatedly!"
},
"siyuan": {
"exporting": "Exporting to Siyuan Note, please do not request export repeatedly!"
},
"yuque": {
"exporting": "Exporting to Yuque, please do not request export repeatedly!"
"export": {
"exporting": "Another export is in progress. Please wait for the previous export to complete and then try again."
}
},
"warning": {
@ -1579,6 +1577,7 @@
"style_type_tip": "Style for edited image, only for V_2 and above"
},
"generate": {
"height": "Height",
"magic_prompt_option_tip": "Intelligently enhances prompts for better results",
"model_tip": "Model version: V3 is the latest version, V2 is the previous model, V2A is the fast model, V_1 is the first-generation model, _TURBO is the acceleration version",
"negative_prompt_tip": "Describe unwanted elements, only for V_1, V_1_TURBO, V_2, and V_2_TURBO",
@ -1586,8 +1585,11 @@
"person_generation": "Generate person",
"person_generation_tip": "Allow model to generate person images",
"rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3",
"safety_tolerance": "Safety Tolerance",
"safety_tolerance_tip": "Controls safety tolerance for image generation, only available for FLUX.1-Kontext-pro",
"seed_tip": "Controls image generation randomness for reproducible results",
"style_type_tip": "Image generation style for V_2 and above"
"style_type_tip": "Image generation style for V_2 and above",
"width": "Width"
},
"generated_image": "Generated Image",
"go_to_settings": "Go to Settings",
@ -1642,7 +1644,7 @@
"prompt_enhancement_tip": "Rewrite prompts into detailed, model-friendly versions when switched on",
"prompt_placeholder": "Describe the image you want to create, e.g. A serene lake at sunset with mountains in the background",
"prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap",
"prompt_placeholder_en": "Enter your image description, currently Imagen only supports English prompts",
"prompt_placeholder_en": "Enter your image description, currently only supports English prompts",
"proxy_required": "Open the proxy and enable \"TUN mode\" to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported",
"quality": "Quality",
"quality_options": {

View File

@ -840,6 +840,9 @@
"created": "作成日",
"last_updated": "最終更新日",
"messages": "メッセージ",
"notion": {
"reasoning_truncated": "思考過程がブロック分割できません。切り捨てられています。"
},
"user": "ユーザー"
},
"files": {
@ -1257,7 +1260,8 @@
},
"notion": {
"export": "Notionへのエクスポートに失敗しました。接続状態と設定を確認してください",
"no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません"
"no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません",
"no_content": "Notionにエクスポートできる内容がありません。"
},
"siyuan": {
"export": "思源ノートのエクスポートに失敗しました。接続状態を確認し、ドキュメントに従って設定を確認してください",
@ -1383,14 +1387,8 @@
}
},
"warn": {
"notion": {
"exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! "
},
"siyuan": {
"exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!"
},
"yuque": {
"exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!"
"export": {
"exporting": "他のエクスポートが実行中です。前のエクスポートが完了するまでお待ちください。"
}
},
"warning": {
@ -1579,6 +1577,7 @@
"style_type_tip": "編集後の画像スタイル、V_2 以上のバージョンでのみ適用"
},
"generate": {
"height": "高さ",
"magic_prompt_option_tip": "生成効果を向上させるための提示詞を最適化します",
"model_tip": "モデルバージョンV2 は最新 API モデル、V2A は高速モデル、V_1 は初代モデル、_TURBO は高速処理版です",
"negative_prompt_tip": "画像に含めたくない内容を説明します",
@ -1586,8 +1585,11 @@
"person_generation": "人物生成",
"person_generation_tip": "人物画像を生成する",
"rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です",
"safety_tolerance": "安全耐性",
"safety_tolerance_tip": "画像生成の安全耐性を制御します。FLUX.1-Kontext-pro のみ利用可能です",
"seed_tip": "画像生成のランダム性を制御して、同じ生成結果を再現します",
"style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用"
"style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用",
"width": "幅"
},
"generated_image": "生成画像",
"go_to_settings": "設定に移動",
@ -1642,7 +1644,7 @@
"prompt_enhancement_tip": "オンにすると、プロンプトを詳細でモデルに適したバージョンに書き直します",
"prompt_placeholder": "作成したい画像を説明します。例:夕日の湖畔、遠くに山々",
"prompt_placeholder_edit": "画像の説明を入力します。テキスト描画には '二重引用符' を使用します",
"prompt_placeholder_en": "「英語」の説明を入力します。Imagenは現在、英語のプロンプト語のみをサポートしています",
"prompt_placeholder_en": "「英語」の説明を入力します。は現在、英語のプロンプト語のみをサポートしています",
"proxy_required": "打開代理並開啟TUN模式查看生成圖片或複製到瀏覽器開啟後續會支持國內直連",
"quality": "品質",
"quality_options": {

View File

@ -840,6 +840,9 @@
"created": "Создано",
"last_updated": "Последнее обновление",
"messages": "Сообщения",
"notion": {
"reasoning_truncated": "Цепочка мыслей не может быть разбита на блоки, обрезана"
},
"user": "Пользователь"
},
"files": {
@ -1257,7 +1260,8 @@
},
"notion": {
"export": "Ошибка экспорта в Notion, пожалуйста, проверьте состояние подключения и настройки в документации",
"no_api_key": "Notion ApiKey или Notion DatabaseID не настроен"
"no_api_key": "Notion ApiKey или Notion DatabaseID не настроен",
"no_content": "Нет содержимого для экспорта в Notion"
},
"siyuan": {
"export": "Ошибка экспорта в Siyuan, пожалуйста, проверьте состояние подключения и настройки в документации",
@ -1383,14 +1387,8 @@
}
},
"warn": {
"notion": {
"exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!"
},
"siyuan": {
"exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!"
},
"yuque": {
"exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!"
"export": {
"exporting": "Выполняется другая экспортация, подождите завершения предыдущей операции экспорта и повторите попытку"
}
},
"warning": {
@ -1579,6 +1577,7 @@
"style_type_tip": "Стиль изображения после редактирования, доступен только для версий V_2 и выше"
},
"generate": {
"height": "Высота",
"magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта генерации",
"model_tip": "Версия модели: V2 - новейшая API модель, V2A - быстрая модель, V_1 - первое поколение, _TURBO - ускоренная версия",
"negative_prompt_tip": "Описывает, что вы не хотите видеть в изображении",
@ -1586,8 +1585,11 @@
"person_generation": "Генерация персонажа",
"person_generation_tip": "Разрешить модель генерировать изображения людей",
"rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3",
"safety_tolerance": "Безопасность",
"safety_tolerance_tip": "Контролирует безопасность изображения, доступно только для FLUX.1-Kontext-pro",
"seed_tip": "Контролирует случайность генерации изображений для воспроизведения одинаковых результатов",
"style_type_tip": "Стиль генерации изображений, доступен только для версий V_2 и выше"
"style_type_tip": "Стиль генерации изображений, доступен только для версий V_2 и выше",
"width": "Ширина"
},
"generated_image": "Сгенерированное изображение",
"go_to_settings": "Перейти в настройки",
@ -1642,7 +1644,7 @@
"prompt_enhancement_tip": "При включении переписывает промпт в более детальную, модель-ориентированную версию",
"prompt_placeholder": "Опишите изображение, которое вы хотите создать, например, Спокойное озеро на закате с горами на заднем плане",
"prompt_placeholder_edit": "Введите ваше описание изображения, текстовая отрисовка использует двойные кавычки для обертки",
"prompt_placeholder_en": "Введите описание изображения, в настоящее время Imagen поддерживает только английские подсказки",
"prompt_placeholder_en": "Введите описание изображения, в настоящее время поддерживает только английские подсказки",
"proxy_required": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение",
"quality": "Качество",
"quality_options": {

View File

@ -840,6 +840,9 @@
"created": "创建时间",
"last_updated": "最后更新",
"messages": "消息数",
"notion": {
"reasoning_truncated": "思维链无法分块,已截断"
},
"user": "用户"
},
"files": {
@ -1257,7 +1260,8 @@
},
"notion": {
"export": "导出 Notion 错误,请检查连接状态并对照文档检查配置",
"no_api_key": "未配置 Notion API Key 或 Notion Database ID"
"no_api_key": "未配置 Notion API Key 或 Notion Database ID",
"no_content": "无可导出到 Notion 的内容"
},
"siyuan": {
"export": "导出思源笔记失败,请检查连接状态并对照文档检查配置",
@ -1383,14 +1387,8 @@
}
},
"warn": {
"notion": {
"exporting": "正在导出到 Notion, 请勿重复请求导出!"
},
"siyuan": {
"exporting": "正在导出到思源笔记,请勿重复请求导出!"
},
"yuque": {
"exporting": "正在导出语雀,请勿重复请求导出!"
"export": {
"exporting": "正在进行其他导出,请等待上一导出完成后重试"
}
},
"warning": {
@ -1579,6 +1577,7 @@
"style_type_tip": "编辑后的图像风格,仅适用于 V_2 及以上版本"
},
"generate": {
"height": "高度",
"magic_prompt_option_tip": "智能优化提示词以提升生成效果",
"model_tip": "模型版本V3 为最新版本V2 为之前版本V2A 为快速模型、V_1 为初代模型_TURBO 为加速版本",
"negative_prompt_tip": "描述不想在图像中出现的元素,仅支持 V_1、V_1_TURBO、V_2 和 V_2_TURBO 版本",
@ -1586,8 +1585,11 @@
"person_generation": "生成人物",
"person_generation_tip": "允许模型生成人物图像",
"rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本",
"safety_tolerance": "安全容忍度",
"safety_tolerance_tip": "控制图像生成的安全容忍度,仅适用于 FLUX.1-Kontext-pro 版本",
"seed_tip": "控制图像生成的随机性,用于复现相同的生成结果",
"style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本"
"style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本",
"width": "宽度"
},
"generated_image": "生成图片",
"go_to_settings": "去设置",
@ -1642,7 +1644,7 @@
"prompt_enhancement_tip": "开启后将提示重写为详细的、适合模型的版本",
"prompt_placeholder": "描述你想创建的图片,例如:一个宁静的湖泊,夕阳西下,远处是群山",
"prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹",
"prompt_placeholder_en": "输入 \"英文\" 图片描述,目前 Imagen 仅支持英文提示词",
"prompt_placeholder_en": "输入 \"英文\" 图片描述,目前仅支持英文提示词",
"proxy_required": "打开代理并开启 \"TUN 模式\" 查看生成图片或复制到浏览器打开,后续会支持国内直连",
"quality": "质量",
"quality_options": {

View File

@ -840,6 +840,9 @@
"created": "建立時間",
"last_updated": "最後更新",
"messages": "訊息數",
"notion": {
"reasoning_truncated": "思維鏈無法分塊,已截斷"
},
"user": "使用者"
},
"files": {
@ -1257,7 +1260,8 @@
},
"notion": {
"export": "匯出 Notion 錯誤,請檢查連接狀態並對照文件檢查設定",
"no_api_key": "未設定 Notion API Key 或 Notion Database ID"
"no_api_key": "未設定 Notion API Key 或 Notion Database ID",
"no_content": "沒有可匯出至 Notion 的內容"
},
"siyuan": {
"export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置",
@ -1383,14 +1387,8 @@
}
},
"warn": {
"notion": {
"exporting": "正在匯出到 Notion請勿重複請求匯出"
},
"siyuan": {
"exporting": "正在導出到思源筆記,請勿重複請求導出!"
},
"yuque": {
"exporting": "正在導出語雀,請勿重複請求導出!"
"export": {
"exporting": "正在進行其他匯出,請等待上一次匯出完成後再試"
}
},
"warning": {
@ -1579,6 +1577,7 @@
"style_type_tip": "編輯後的圖像風格,僅適用於 V_2 及以上版本"
},
"generate": {
"height": "高度",
"magic_prompt_option_tip": "智能優化生成效果的提示詞",
"model_tip": "模型版本V2 是最新 API 模型V2A 是高速模型V_1 是初代模型_TURBO 是高速處理版",
"negative_prompt_tip": "描述不想在圖像中出現的內容",
@ -1586,8 +1585,11 @@
"person_generation": "人物生成",
"person_generation_tip": "允許模型生成人物圖像",
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本",
"safety_tolerance": "安全耐性",
"safety_tolerance_tip": "控制圖像生成的安全耐性,僅適用於 FLUX.1-Kontext-pro 版本",
"seed_tip": "控制圖像生成的隨機性,以重現相同的生成結果",
"style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本"
"style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本",
"width": "寬度"
},
"generated_image": "生成圖片",
"go_to_settings": "去設置",
@ -1642,7 +1644,7 @@
"prompt_enhancement_tip": "開啟後將提示重寫為詳細的、適合模型的版本",
"prompt_placeholder": "描述你想建立的圖片,例如:一個寧靜的湖泊,夕陽西下,遠處是群山",
"prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 ' 雙引號 ' 包裹",
"prompt_placeholder_en": "輸入英文圖片描述,目前 Imagen 僅支持英文提示詞",
"prompt_placeholder_en": "輸入英文圖片描述,目前僅支持英文提示詞",
"proxy_required": "打開代理並開啟”TUN 模式 “查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連",
"quality": "品質",
"quality_options": {

View File

@ -648,6 +648,31 @@
},
"translate": "Μετάφραση"
},
"code": {
"auto_update_to_latest": "Έλεγχος για ενημερώσεις και εγκατάσταση της τελευταίας έκδοσης",
"bun_required_message": "Για τη λειτουργία του εργαλείου CLI πρέπει να εγκαταστήσετε το περιβάλλον Bun",
"cli_tool": "Εργαλείο CLI",
"cli_tool_placeholder": "Επιλέξτε το CLI εργαλείο που θέλετε να χρησιμοποιήσετε",
"description": "Εκκίνηση γρήγορα πολλών εργαλείων CLI κώδικα, για αύξηση της αποδοτικότητας ανάπτυξης",
"folder_placeholder": "Επιλέξτε κατάλογο εργασίας",
"install_bun": "Εγκατάσταση Bun",
"installing_bun": "Εγκατάσταση...",
"launch": {
"bun_required": "Παρακαλώ εγκαταστήστε πρώτα το περιβάλλον Bun πριν εκκινήσετε το εργαλείο CLI",
"error": "Η εκκίνηση απέτυχε, παρακαλώ δοκιμάστε ξανά",
"label": "Εκκίνηση",
"success": "Επιτυχής εκκίνηση",
"validation_error": "Συμπληρώστε όλα τα υποχρεωτικά πεδία: εργαλείο CLI, μοντέλο και κατάλογος εργασίας"
},
"launching": "Εκκίνηση...",
"model": "μοντέλο",
"model_placeholder": "Επιλέξτε το μοντέλο που θα χρησιμοποιήσετε",
"model_required": "Επιλέξτε μοντέλο",
"select_folder": "Επιλογή φακέλου",
"title": "Εργαλεία κώδικα",
"update_options": "Ενημέρωση επιλογών",
"working_directory": "κατάλογος εργασίας"
},
"code_block": {
"collapse": "συμπεριληφθείς",
"copy": {
@ -815,6 +840,9 @@
"created": "Ημερομηνία Δημιουργίας",
"last_updated": "Τελευταία ενημέρωση",
"messages": "Αριθμός Μηνυμάτων",
"notion": {
"reasoning_truncated": "Η αλυσίδα σκέψης δεν μπορεί να διαιρεθεί, έχει κοπεί"
},
"user": "Χρήστης"
},
"files": {
@ -1232,7 +1260,8 @@
},
"notion": {
"export": "Σφάλμα στην εξαγωγή του Notion, παρακαλείστε να ελέγξετε τη σύνδεση και τη διαμόρφωση κατά τη διατύπωση του χειρισμού",
"no_api_key": "Δεν έχετε διαθέσιμο το API Key του Notion ή το ID της βάσης του Notion"
"no_api_key": "Δεν έχετε διαθέσιμο το API Key του Notion ή το ID της βάσης του Notion",
"no_content": "Δεν υπάρχει περιεχόμενο για εξαγωγή στο Notion"
},
"siyuan": {
"export": "Η έκθεση σημειώσεων Siyuan απέτυχε, ελέγξτε την κατάσταση σύνδεσης και τις ρυθμίσεις σύμφωνα με τα έγγραφα",
@ -1358,14 +1387,8 @@
}
},
"warn": {
"notion": {
"exporting": "Εξαγωγή στο Notion, μην επαναλάβετε την διαδικασία εξαγωγής!"
},
"siyuan": {
"exporting": "Γίνεται εξαγωγή στις σημειώσεις Siyuan· μην ξαναζητήσετε την έκθεση!"
},
"yuque": {
"exporting": "Γίνεται έκθεση Yuque· μην ξαναζητήσετε την έκθεση!"
"export": {
"exporting": "Παρακαλώ περιμένετε την ολοκλήρωση της προηγούμενης εξαγωγής. Εκτελείται άλλη εξαγωγή."
}
},
"warning": {
@ -2690,6 +2713,17 @@
"title": "Εκκίνηση",
"totray": "Εισαγωγή στην συνδρομή κατά την εκκίνηση"
},
"math": {
"engine": {
"label": "Μηχανισμός μαθηματικών τύπων",
"none": "κανένα"
},
"single_dollar": {
"label": "ενεργοποίηση $...$",
"tip": "Επεξεργασία μαθηματικών τύπων που περικλείονται σε ένα μόνο σύμβολο δολαρίου $...$, προεπιλογή ενεργοποιημένη."
},
"title": "Ρύθμιση μαθηματικών τύπων"
},
"mcp": {
"actions": "Ενέργειες",
"active": "Ενεργοποίηση",
@ -2920,10 +2954,6 @@
"title": "Ρυθμίσεις εισαγωγής"
},
"markdown_rendering_input_message": "Markdown Rendering Input Message",
"math_engine": {
"label": "Μηχανική μαθηματικών εξισώσεων",
"none": "Κανένα"
},
"metrics": "Χρόνος πρώτου χαρακτήρα {{time_first_token_millsec}}ms | {{token_speed}} tokens ανά δευτερόλεπτο",
"model": {
"title": "Ρυθμίσεις μοντέλου"
@ -2935,6 +2965,7 @@
"none": "Χωρίς εμφάνιση"
},
"prompt": "Λήμμα προτροπής",
"show_message_outline": "Εμφάνιση πλαισίου μηνύματος",
"title": "Ρυθμίσεις μηνυμάτων",
"use_serif_font": "Χρήση μορφής Serif"
},
@ -3561,6 +3592,7 @@
"title": {
"agents": "Πράκτορες",
"apps": "Εφαρμογές",
"code": "Κώδικας",
"files": "Αρχεία",
"home": "Αρχική Σελίδα",
"knowledge": "Βάση Γνώσης",

View File

@ -648,6 +648,31 @@
},
"translate": "Traducir"
},
"code": {
"auto_update_to_latest": "Comprobar actualizaciones e instalar la versión más reciente",
"bun_required_message": "Se requiere instalar el entorno Bun para ejecutar la herramienta de línea de comandos",
"cli_tool": "Herramienta de línea de comandos",
"cli_tool_placeholder": "Seleccione la herramienta de línea de comandos que desea utilizar",
"description": "Inicia rápidamente múltiples herramientas de línea de comandos para código, aumentando la eficiencia del desarrollo",
"folder_placeholder": "Seleccionar directorio de trabajo",
"install_bun": "Instalar Bun",
"installing_bun": "Instalando...",
"launch": {
"bun_required": "Instale el entorno Bun antes de iniciar la herramienta de línea de comandos",
"error": "Error al iniciar, intente nuevamente",
"label": "Iniciar",
"success": "Inicio exitoso",
"validation_error": "Complete all required fields: CLI tool, model, and working directory"
},
"launching": "Iniciando...",
"model": "modelo",
"model_placeholder": "Seleccionar el modelo que se va a utilizar",
"model_required": "Seleccione el modelo",
"select_folder": "Seleccionar carpeta",
"title": "Herramientas de código",
"update_options": "Opciones de actualización",
"working_directory": "directorio de trabajo"
},
"code_block": {
"collapse": "Replegar",
"copy": {
@ -815,6 +840,9 @@
"created": "Fecha de creación",
"last_updated": "Última actualización",
"messages": "Mensajes",
"notion": {
"reasoning_truncated": "La cadena de pensamiento no se puede dividir en bloques, ha sido truncada"
},
"user": "Usuario"
},
"files": {
@ -1232,7 +1260,8 @@
},
"notion": {
"export": "Error de exportación de Notion, verifique el estado de conexión y la configuración según la documentación",
"no_api_key": "No se ha configurado la clave API de Notion o la ID de la base de datos de Notion"
"no_api_key": "No se ha configurado la clave API de Notion o la ID de la base de datos de Notion",
"no_content": "No hay contenido que exportar a Notion"
},
"siyuan": {
"export": "Error al exportar la nota de Siyuan, verifique el estado de la conexión y revise la configuración según la documentación",
@ -1358,14 +1387,8 @@
}
},
"warn": {
"notion": {
"exporting": "Se está exportando a Notion, ¡no solicite nuevamente la exportación!"
},
"siyuan": {
"exporting": "Exportando a Siyuan, ¡no solicite la exportación nuevamente!"
},
"yuque": {
"exporting": "Exportando Yuque, ¡no solicite la exportación nuevamente!"
"export": {
"exporting": "Realizando otra exportación, espere a que finalice la anterior para intentarlo de nuevo"
}
},
"warning": {
@ -2690,6 +2713,17 @@
"title": "Inicio",
"totray": "Minimizar a la bandeja al iniciar"
},
"math": {
"engine": {
"label": "Motor de fórmulas matemáticas",
"none": "sin contenido"
},
"single_dollar": {
"label": "habilitar $...$",
"tip": "Renderiza fórmulas matemáticas encerradas entre un único símbolo de dólar $...$, habilitado por defecto."
},
"title": "Configuración de fórmulas matemáticas"
},
"mcp": {
"actions": "Acciones",
"active": "Activar",
@ -2920,10 +2954,6 @@
"title": "Configuración de entrada"
},
"markdown_rendering_input_message": "Renderizar mensajes de entrada en Markdown",
"math_engine": {
"label": "Motor de fórmulas matemáticas",
"none": "Ninguno"
},
"metrics": "Retraso inicial {{time_first_token_millsec}}ms | {{token_speed}} tokens por segundo",
"model": {
"title": "Configuración del modelo"
@ -2935,6 +2965,7 @@
"none": "No mostrar"
},
"prompt": "Palabra de indicación",
"show_message_outline": "Mostrar esquema del mensaje",
"title": "Configuración de mensajes",
"use_serif_font": "Usar fuente serif"
},
@ -3561,6 +3592,7 @@
"title": {
"agents": "Agentes",
"apps": "Aplicaciones",
"code": "Código",
"files": "Archivos",
"home": "Inicio",
"knowledge": "Base de conocimiento",

View File

@ -648,6 +648,31 @@
},
"translate": "Traduire"
},
"code": {
"auto_update_to_latest": "Vérifier les mises à jour et installer la dernière version",
"bun_required_message": "L'exécution de l'outil en ligne de commande nécessite l'installation de l'environnement Bun",
"cli_tool": "Outil CLI",
"cli_tool_placeholder": "Sélectionnez l'outil CLI à utiliser",
"description": "Lancer rapidement plusieurs outils CLI de code pour améliorer l'efficacité du développement",
"folder_placeholder": "Sélectionner le répertoire de travail",
"install_bun": "Installer Bun",
"installing_bun": "Installation en cours...",
"launch": {
"bun_required": "Veuillez d'abord installer l'environnement Bun avant de lancer l'outil en ligne de commande",
"error": "Échec du démarrage, veuillez réessayer",
"label": "Démarrer",
"success": "Démarrage réussi",
"validation_error": "Veuillez remplir tous les champs obligatoires : outil CLI, modèle et répertoire de travail"
},
"launching": "En cours de démarrage...",
"model": "modèle",
"model_placeholder": "Sélectionnez le modèle à utiliser",
"model_required": "Veuillez sélectionner le modèle",
"select_folder": "Sélectionner le dossier",
"title": "Outils de code",
"update_options": "Options de mise à jour",
"working_directory": "répertoire de travail"
},
"code_block": {
"collapse": "Réduire",
"copy": {
@ -815,6 +840,9 @@
"created": "Date de création",
"last_updated": "Dernière mise à jour",
"messages": "Messages",
"notion": {
"reasoning_truncated": "La chaîne de pensée ne peut pas être fractionnée, elle a été tronquée."
},
"user": "Utilisateur"
},
"files": {
@ -1232,7 +1260,8 @@
},
"notion": {
"export": "Erreur lors de l'exportation vers Notion, veuillez vérifier l'état de la connexion et la configuration dans la documentation",
"no_api_key": "Aucune clé API Notion ou ID de base de données Notion configurée"
"no_api_key": "Aucune clé API Notion ou ID de base de données Notion configurée",
"no_content": "Aucun contenu à exporter vers Notion"
},
"siyuan": {
"export": "Échec de l'exportation de la note Siyuan, veuillez vérifier l'état de la connexion et la configuration indiquée dans le document",
@ -1358,14 +1387,8 @@
}
},
"warn": {
"notion": {
"exporting": "Exportation en cours vers Notion, veuillez ne pas faire plusieurs demandes d'exportation!"
},
"siyuan": {
"exporting": "Exportation vers Siyuan en cours, veuillez ne pas demander à exporter à nouveau !"
},
"yuque": {
"exporting": "Exportation Yuque en cours, veuillez ne pas demander à exporter à nouveau !"
"export": {
"exporting": "Une autre exportation est en cours, veuillez patienter jusqu'à la fin de l'exportation précédente pour réessayer."
}
},
"warning": {
@ -2690,6 +2713,17 @@
"title": "Démarrage",
"totray": "Minimiser dans la barre d'état système au démarrage"
},
"math": {
"engine": {
"label": "Moteur de formules mathématiques",
"none": "Aucun"
},
"single_dollar": {
"label": "activer $...$",
"tip": "Rendu des formules mathématiques encapsulées par un seul symbole dollar $...$, activé par défaut."
},
"title": "Configuration des formules mathématiques"
},
"mcp": {
"actions": "Actions",
"active": "Activer",
@ -2920,10 +2954,6 @@
"title": "Paramètres d'entrée"
},
"markdown_rendering_input_message": "Rendu Markdown des messages d'entrée",
"math_engine": {
"label": "Moteur de formules mathématiques",
"none": "Aucun"
},
"metrics": "Latence initiale {{time_first_token_millsec}}ms | Vitesse de tokenisation {{token_speed}} tokens/s",
"model": {
"title": "Paramètres du modèle"
@ -2935,6 +2965,7 @@
"none": "Ne pas afficher"
},
"prompt": "Mot-clé d'affichage",
"show_message_outline": "Afficher le plan du message",
"title": "Paramètres des messages",
"use_serif_font": "Utiliser une police serif"
},
@ -3561,6 +3592,7 @@
"title": {
"agents": "Agent intelligent",
"apps": "Mini-programmes",
"code": "Code",
"files": "Fichiers",
"home": "Page d'accueil",
"knowledge": "Base de connaissances",

View File

@ -648,6 +648,31 @@
},
"translate": "Traduzir"
},
"code": {
"auto_update_to_latest": "Verificar atualizações e instalar a versão mais recente",
"bun_required_message": "Executar a ferramenta CLI requer a instalação do ambiente Bun",
"cli_tool": "Ferramenta de linha de comando",
"cli_tool_placeholder": "Selecione a ferramenta de linha de comando a ser utilizada",
"description": "Inicie rapidamente várias ferramentas de linha de comando de código, aumentando a eficiência do desenvolvimento",
"folder_placeholder": "Selecionar diretório de trabalho",
"install_bun": "Instalar o Bun",
"installing_bun": "Instalando...",
"launch": {
"bun_required": "Instale o ambiente Bun antes de iniciar a ferramenta de linha de comando",
"error": "Falha ao iniciar, tente novamente",
"label": "iniciar",
"success": "Início bem-sucedido",
"validation_error": "Preencha todos os campos obrigatórios: ferramenta CLI, modelo e diretório de trabalho"
},
"launching": "Iniciando...",
"model": "modelo",
"model_placeholder": "Selecione o modelo a ser utilizado",
"model_required": "Selecione o modelo",
"select_folder": "Selecionar pasta",
"title": "Ferramenta de código",
"update_options": "Opções de atualização",
"working_directory": "diretório de trabalho"
},
"code_block": {
"collapse": "Recolher",
"copy": {
@ -815,6 +840,9 @@
"created": "Criado em",
"last_updated": "Última Atualização",
"messages": "Mensagens",
"notion": {
"reasoning_truncated": "A cadeia de pensamento não pode ser dividida em partes, foi interrompida"
},
"user": "Usuário"
},
"files": {
@ -1232,7 +1260,8 @@
},
"notion": {
"export": "Erro ao exportar Notion, verifique o status da conexão e a configuração de acordo com a documentação",
"no_api_key": "API Key ou Notion Database ID não configurados"
"no_api_key": "API Key ou Notion Database ID não configurados",
"no_content": "Nenhum conteúdo para exportar para o Notion"
},
"siyuan": {
"export": "Falha ao exportar nota do Siyuan, verifique o estado da conexão e confira a configuração no documento",
@ -1358,14 +1387,8 @@
}
},
"warn": {
"notion": {
"exporting": "Exportando para Notion, não solicite novamente a exportação!"
},
"siyuan": {
"exporting": "Exportando para o Siyuan, por favor não solicite a exportação novamente!"
},
"yuque": {
"exporting": "Exportando para Yuque, por favor não solicite a exportação novamente!"
"export": {
"exporting": "A exportação de outros arquivos está em andamento, aguarde a conclusão da exportação anterior e tente novamente."
}
},
"warning": {
@ -2690,6 +2713,17 @@
"title": "Inicialização",
"totray": "Minimizar para bandeja ao iniciar"
},
"math": {
"engine": {
"label": "Motor de fórmulas matemáticas",
"none": "sem conteúdo"
},
"single_dollar": {
"label": "ativar $...$",
"tip": "Renderiza fórmulas matemáticas delimitadas por um único sinal de dólar $...$, habilitado por padrão."
},
"title": "Configuração de fórmulas matemáticas"
},
"mcp": {
"actions": "Ações",
"active": "Ativar",
@ -2920,10 +2954,6 @@
"title": "Configurações de entrada"
},
"markdown_rendering_input_message": "Renderização de markdown na entrada de mensagens",
"math_engine": {
"label": "Motor de fórmulas matemáticas",
"none": "Nenhum"
},
"metrics": "Atraso inicial {{time_first_token_millsec}}ms | Taxa de token por segundo {{token_speed}} tokens",
"model": {
"title": "Configurações de modelo"
@ -2935,6 +2965,7 @@
"none": "Não mostrar"
},
"prompt": "Exibir palavra-chave",
"show_message_outline": "Exibir esboço da mensagem",
"title": "Configurações de mensagem",
"use_serif_font": "Usar fonte serif"
},
@ -3561,6 +3592,7 @@
"title": {
"agents": "Agentes",
"apps": "Miniaplicativos",
"code": "Código",
"files": "Arquivos",
"home": "Página Inicial",
"knowledge": "Base de Conhecimento",

View File

@ -24,7 +24,7 @@ const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEna
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" disabled={!isGenerateImageModel(model)} onClick={onEnableGenerateImage}>
<Image size={18} color={assistant.enableGenerateImage ? 'var(--color-link)' : 'var(--color-icon)'} />
<Image size={18} color={assistant.enableGenerateImage ? 'var(--color-primary)' : 'var(--color-icon)'} />
</ToolbarButton>
</Tooltip>
)

View File

@ -530,7 +530,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
inputbarToolsRef.current?.openMentionModelsPanel()
inputbarToolsRef.current?.openMentionModelsPanel({
type: 'input',
position: cursorPosition - 1,
originalText: newText
})
}
},
[enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate]

View File

@ -49,7 +49,7 @@ export interface InputbarToolsRef {
openSelectFileMenu: () => void
translate: () => void
}) => QuickPanelListItem[]
openMentionModelsPanel: () => void
openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
openAttachmentQuickPanel: () => void
}
@ -294,7 +294,7 @@ const InputbarTools = ({
useImperativeHandle(ref, () => ({
getQuickPanelMenu: getQuickPanelMenuImpl,
openMentionModelsPanel: () => mentionModelsButtonRef.current?.openQuickPanel(),
openMentionModelsPanel: (triggerInfo) => mentionModelsButtonRef.current?.openQuickPanel(triggerInfo),
openAttachmentQuickPanel: () => attachmentButtonRef.current?.openQuickPanel()
}))

View File

@ -87,7 +87,10 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
return (
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel} disabled={disabled}>
<FileSearch size={18} />
<FileSearch
size={18}
color={selectedBases && selectedBases.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'}
/>
</ToolbarButton>
</Tooltip>
)

View File

@ -17,7 +17,7 @@ import { useNavigate } from 'react-router'
import styled from 'styled-components'
export interface MentionModelsButtonRef {
openQuickPanel: () => void
openQuickPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
}
interface Props {
@ -137,42 +137,67 @@ const MentionModelsButton: FC<Props> = ({
return items
}, [pinnedModels, providers, t, couldMentionNotVisionModel, mentionedModels, onMentionModel, navigate])
const openQuickPanel = useCallback(() => {
// 重置模型动作标记
hasModelActionRef.current = false
const openQuickPanel = useCallback(
(triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => {
// 重置模型动作标记
hasModelActionRef.current = false
quickPanel.open({
title: t('agents.edit.model.select.title'),
list: modelItems,
symbol: '@',
multiple: true,
afterAction({ item }) {
item.isSelected = !item.isSelected
},
onClose({ action }) {
// ESC或Backspace关闭时的特殊处理
if (action === 'esc' || action === 'delete-symbol') {
// 如果有模型选择动作发生,删除@字符
if (hasModelActionRef.current) {
// 使用React的setText来更新状态而不是直接操作DOM
setText((currentText) => {
const lastAtIndex = currentText.lastIndexOf('@')
if (lastAtIndex !== -1) {
return currentText.slice(0, lastAtIndex) + currentText.slice(lastAtIndex + 1)
}
return currentText
})
quickPanel.open({
title: t('agents.edit.model.select.title'),
list: modelItems,
symbol: '@',
multiple: true,
triggerInfo: triggerInfo || { type: 'button' },
afterAction({ item }) {
item.isSelected = !item.isSelected
},
onClose({ action, triggerInfo: closeTriggerInfo, searchText }) {
// ESC关闭时的处理删除 @ 和搜索文本
if (action === 'esc') {
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
if (
hasModelActionRef.current &&
closeTriggerInfo?.type === 'input' &&
closeTriggerInfo?.position !== undefined
) {
// 使用React的setText来更新状态
setText((currentText) => {
const position = closeTriggerInfo.position!
// 验证位置的字符是否仍是 @
if (currentText[position] !== '@') {
return currentText
}
// 计算删除范围:@ + searchText
const deleteLength = 1 + (searchText?.length || 0)
// 验证要删除的内容是否匹配预期
const expectedText = '@' + (searchText || '')
const actualText = currentText.slice(position, position + deleteLength)
if (actualText !== expectedText) {
// 如果实际文本不匹配,只删除 @ 字符
return currentText.slice(0, position) + currentText.slice(position + 1)
}
// 删除 @ 和搜索文本
return currentText.slice(0, position) + currentText.slice(position + deleteLength)
})
}
}
// Backspace删除@的情况delete-symbol
// @ 已经被Backspace自然删除面板关闭不需要额外操作
}
}
})
}, [modelItems, quickPanel, t, setText])
})
},
[modelItems, quickPanel, t, setText]
)
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === '@') {
quickPanel.close()
} else {
openQuickPanel()
openQuickPanel({ type: 'button' })
}
}, [openQuickPanel, quickPanel])
@ -195,7 +220,7 @@ const MentionModelsButton: FC<Props> = ({
return (
<Tooltip placement="top" title={t('agents.edit.model.select.title')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<AtSign size={18} />
<AtSign size={18} color={mentionedModels.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'} />
</ToolbarButton>
</Tooltip>
)

View File

@ -33,7 +33,7 @@ const UrlContextButton: FC<Props> = ({ assistant, ToolbarButton }) => {
<Link
size={18}
style={{
color: assistant.enableUrlContext ? 'var(--color-link)' : 'var(--color-icon)'
color: assistant.enableUrlContext ? 'var(--color-primary)' : 'var(--color-icon)'
}}
/>
</ToolbarButton>

View File

@ -28,7 +28,9 @@ import { Pluggable } from 'unified'
import CodeBlock from './CodeBlock'
import Link from './Link'
import MarkdownSvgRenderer from './MarkdownSvgRenderer'
import rehypeHeadingIds from './plugins/rehypeHeadingIds'
import rehypeScalableSvg from './plugins/rehypeScalableSvg'
import remarkDisableConstructs from './plugins/remarkDisableConstructs'
import Table from './Table'
@ -112,7 +114,7 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
const rehypePlugins = useMemo(() => {
const plugins: Pluggable[] = []
if (ALLOWED_ELEMENTS.test(messageContent)) {
plugins.push(rehypeRaw)
plugins.push(rehypeRaw, rehypeScalableSvg)
}
plugins.push([rehypeHeadingIds, { prefix: `heading-${block.id}` }])
if (mathEngine === 'KaTeX') {
@ -147,7 +149,8 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img')
if (hasImage) return <div {...props} />
return <p {...props} />
}
},
svg: MarkdownSvgRenderer
} as Partial<Components>
}, [onSaveCodeBlock, block.id])

View File

@ -0,0 +1,76 @@
import { ImagePreviewService } from '@renderer/services/ImagePreviewService'
import { makeSvgSizeAdaptive } from '@renderer/utils/image'
import { Dropdown } from 'antd'
import { Eye } from 'lucide-react'
import React, { FC, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
interface SvgProps extends React.SVGProps<SVGSVGElement> {
'data-needs-measurement'?: 'true'
}
/**
* A smart SVG renderer for Markdown content.
*
* This component handles two types of SVGs passed from `react-markdown`:
*
* 1. **Pre-processed SVGs**: Simple SVGs that were already handled by the
* `rehypeScalableSvg` plugin. These are rendered directly.
*
* 2. **SVGs needing measurement**: Complex SVGs are flagged with
* `data-needs-measurement`. This component performs a one-time DOM
* mutation upon mounting to make them scalable. To prevent React from
* reverting these changes during subsequent renders, it stops passing
* the original `width` and `height` props after the mutation is complete.
*/
const MarkdownSvgRenderer: FC<SvgProps> = (props) => {
const { 'data-needs-measurement': needsMeasurement, ...restProps } = props
const svgRef = useRef<SVGSVGElement>(null)
const isMeasuredRef = useRef(false)
const { t } = useTranslation()
useEffect(() => {
if (needsMeasurement && svgRef.current && !isMeasuredRef.current) {
// Directly mutate the DOM element to make it adaptive.
makeSvgSizeAdaptive(svgRef.current)
// Set flag to prevent re-measuring. This does not trigger a re-render.
isMeasuredRef.current = true
}
}, [needsMeasurement])
const onPreview = useCallback(() => {
if (!svgRef.current) return
ImagePreviewService.show(svgRef.current, { format: 'svg' })
}, [])
const contextMenuItems = useMemo(
() => [
{
key: 'preview',
label: t('common.preview'),
icon: <Eye size="1rem" />,
onClick: onPreview
}
],
[onPreview, t]
)
// Create a mutable copy of props to potentially modify.
const finalProps = { ...restProps }
// If the SVG has been measured and mutated, we prevent React from
// re-applying the original width and height attributes on subsequent renders.
// This preserves the changes made by `makeSvgSizeAdaptive`.
if (isMeasuredRef.current) {
delete finalProps.width
delete finalProps.height
}
return (
<Dropdown menu={{ items: contextMenuItems }} trigger={['contextMenu']}>
<svg ref={svgRef} {...finalProps} />
</Dropdown>
)
}
export default MarkdownSvgRenderer

View File

@ -68,9 +68,9 @@ vi.mock('../CodeBlock', () => ({
)
}))
vi.mock('../ImagePreview', () => ({
vi.mock('@renderer/components/ImageViewer', () => ({
__esModule: true,
default: (props: any) => <img data-testid="image-preview" {...props} />
default: (props: any) => <img data-testid="image-viewer" {...props} />
}))
vi.mock('../Link', () => ({
@ -94,12 +94,18 @@ vi.mock('../Table', () => ({
)
}))
vi.mock('../MarkdownSvgRenderer', () => ({
__esModule: true,
default: ({ children }: any) => <div data-testid="svg-renderer">{children}</div>
}))
vi.mock('@renderer/components/MarkdownShadowDOMRenderer', () => ({
__esModule: true,
default: ({ children }: any) => <div data-testid="shadow-dom">{children}</div>
}))
// Mock plugins
vi.mock('remark-alert', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('remark-gfm', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('remark-cjk-friendly', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('remark-math', () => ({ __esModule: true, default: vi.fn() }))
@ -113,6 +119,16 @@ vi.mock('../plugins/remarkDisableConstructs', () => ({
default: vi.fn()
}))
vi.mock('../plugins/rehypeHeadingIds', () => ({
__esModule: true,
default: vi.fn()
}))
vi.mock('../plugins/rehypeScalableSvg', () => ({
__esModule: true,
default: vi.fn()
}))
// Mock ReactMarkdown with realistic rendering
vi.mock('react-markdown', () => ({
__esModule: true,
@ -331,7 +347,7 @@ describe('Markdown', () => {
expect(tableComponent).toHaveAttribute('data-block-id', 'test-block-456')
})
it('should integrate ImagePreview component', () => {
it('should integrate ImageViewer component', () => {
render(<Markdown block={createMainTextBlock()} />)
expect(screen.getByTestId('has-img-component')).toBeInTheDocument()

View File

@ -0,0 +1,65 @@
import type { Element, Root } from 'hast'
import { visit } from 'unist-util-visit'
const isNumeric = (value: unknown): boolean => {
if (typeof value === 'string' && value.trim() !== '') {
return String(parseFloat(value)) === value.trim()
}
return false
}
/**
* A Rehype plugin that prepares SVG elements for scalable rendering.
*
* This plugin classifies SVGs into two categories:
*
* 1. **Simple SVGs**: Those that already have a `viewBox` or have unitless
* numeric `width` and `height` attributes. These are processed directly
* in the HAST tree for maximum performance. A `viewBox` is added if
* missing, and fixed dimensions are removed.
*
* 2. **Complex SVGs**: Those without a `viewBox` and with dimensions that
* have units (e.g., "100pt", "10em"). These cannot be safely processed
* at the data layer. The plugin adds a `data-needs-measurement="true"`
* attribute to them, flagging them for runtime processing by a
* specialized React component.
*
* @returns A unified transformer function.
*/
function rehypeScalableSvg() {
return (tree: Root) => {
visit(tree, 'element', (node: Element) => {
if (node.tagName === 'svg') {
const properties = node.properties || {}
const hasViewBox = 'viewBox' in properties
const width = properties.width as string | undefined
const height = properties.height as string | undefined
// 1. Universally set max-width from the width attribute if it exists.
// This is safe for both simple and complex cases.
if (width) {
const existingStyle = properties.style ? String(properties.style).trim().replace(/;$/, '') : ''
const maxWidth = `max-width: ${width}`
properties.style = existingStyle ? `${existingStyle}; ${maxWidth}` : maxWidth
}
// 2. Handle viewBox creation for simple, numeric cases.
if (!hasViewBox && isNumeric(width) && isNumeric(height)) {
properties.viewBox = `0 0 ${width} ${height}`
}
// 3. Flag complex cases for runtime measurement.
else if (!hasViewBox && width && height) {
properties['data-needs-measurement'] = 'true'
}
// 4. Reset or clean up attributes.
properties.width = '100%'
delete properties.height
node.properties = properties
}
})
}
}
export default rehypeScalableSvg

View File

@ -142,7 +142,7 @@ const CustomNode: FC<{ data: any }> = ({ data }) => {
color="rgba(0, 0, 0, 0.85)"
mouseEnterDelay={0.3}
mouseLeaveDelay={0.1}
destroyTooltipOnHide>
destroyOnHidden>
<CustomNodeContainer
style={{
borderColor,

View File

@ -241,7 +241,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
return (
<Popover
key={message.id}
destroyTooltipOnHide
destroyOnHidden
content={
<MessageWrapper
className={classNames([

View File

@ -319,6 +319,18 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
headers = {
Authorization: `Bearer ${aihubmixProvider.apiKey}`
}
} else if (painting.model === 'FLUX.1-Kontext-pro') {
requestData = {
prompt,
model: painting.model,
// width: painting.width,
// height: painting.height,
safety_tolerance: painting.safetyTolerance || 6
}
url = aihubmixProvider.apiHost + `/v1/images/generations`
headers = {
Authorization: `Bearer ${aihubmixProvider.apiKey}`
}
} else {
// Existing V1/V2 API
requestData = {
@ -475,6 +487,17 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
const data = await response.json()
logger.silly(`通用API响应: ${data}`)
if (data.output) {
const base64s = data.output.b64_json.map((item) => item.bytesBase64)
const validFiles = await Promise.all(
base64s.map(async (base64) => {
return await window.api.file.saveBase64Image(base64)
})
)
await FileManager.addFiles(validFiles)
updatePaintingState({ files: validFiles, urls: validFiles.map((file) => file.name) })
return
}
const urls = data.data.filter((item) => item.url).map((item) => item.url)
const base64s = data.data.filter((item) => item.b64_json).map((item) => item.b64_json)
@ -864,7 +887,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
placeholder={
isTranslating
? t('paintings.translating')
: painting.model?.startsWith('imagen-')
: painting.model?.startsWith('imagen-') || painting.model?.startsWith('FLUX')
? t('paintings.prompt_placeholder_en')
: t('paintings.prompt_placeholder_edit')
}

View File

@ -13,7 +13,7 @@ import FileManager from '@renderer/services/FileManager'
import { useAppDispatch } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import type { FileMetadata, PaintingsState } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { convertToBase64, uuid } from '@renderer/utils'
import { DmxapiPainting } from '@types'
import { Avatar, Button, Input, InputNumber, Segmented, Select, Switch, Tooltip } from 'antd'
import TextArea from 'antd/es/input/TextArea'
@ -364,7 +364,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
}
// 准备V1生成请求函数
const prepareV1GenerateRequest = (prompt: string, painting: DmxapiPainting) => {
const prepareV1GenerateRequest = async (prompt: string, painting: DmxapiPainting) => {
const params = {
prompt,
model: painting.model,
@ -391,6 +391,13 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
params.prompt = prompt + ',风格:' + painting.style_type
}
if (Array.isArray(fileMap.imageFiles) && fileMap.imageFiles.length > 0) {
const imageFile = fileMap.imageFiles[0]
if (imageFile instanceof File) {
params['image'] = await convertToBase64(imageFile)
}
}
return {
body: JSON.stringify(params),
headerExpand: headerExpand,
@ -508,13 +515,17 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
}
// 准备请求配置函数
const prepareRequestConfig = (prompt: string, painting: DmxapiPainting) => {
const prepareRequestConfig = async (prompt: string, painting: DmxapiPainting) => {
// 根据模式和模型版本返回不同的请求配置
if (
painting.generationMode !== undefined &&
[generationModeType.MERGE, generationModeType.EDIT].includes(painting.generationMode)
) {
return prepareV2GenerateRequest(prompt, painting)
if (painting.model === 'seededit-3.0') {
return await prepareV1GenerateRequest(prompt, painting)
} else {
return prepareV2GenerateRequest(prompt, painting)
}
} else {
return prepareV1GenerateRequest(prompt, painting)
}
@ -550,7 +561,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
dispatch(setGenerating(true))
// 准备请求配置
const requestConfig = prepareRequestConfig(prompt, painting)
const requestConfig = await prepareRequestConfig(prompt, painting)
// 发送API请求
const urls = await callApi(requestConfig, controller)

View File

@ -83,7 +83,7 @@ export const MODEOPTIONS = [
// 获取模型分组数据
export const GetModelGroup = async (): Promise<DMXApiModelGroups> => {
try {
const response = await fetch('https://dmxapi.cn/cherry_painting_models.json')
const response = await fetch('https://dmxapi.cn/cherry_painting_models_v2.json')
if (response.ok) {
const data = await response.json()

View File

@ -88,6 +88,11 @@ export const createModeConfigs = (): Record<AihubmixMode, ConfigItem[]> => {
{ label: 'ideogram_V_1', value: 'V_1' },
{ label: 'ideogram_V_1_TURBO', value: 'V_1_TURBO' }
]
},
{
label: 'Flux',
title: 'Flux',
options: [{ label: 'FLUX.1-Kontext-pro', value: 'FLUX.1-Kontext-pro' }]
}
]
},
@ -229,6 +234,36 @@ export const createModeConfigs = (): Record<AihubmixMode, ConfigItem[]> => {
options: PERSON_GENERATION_OPTIONS,
initialValue: 'ALLOW_ALL',
condition: (painting) => Boolean(painting.model?.startsWith('imagen-'))
},
// {
// type: 'slider',
// key: 'width',
// title: 'paintings.generate.width',
// min: 256,
// max: 1440,
// initialValue: 1024,
// step: 32,
// condition: (painting) => painting.model === 'FLUX.1-Kontext-pro'
// },
// {
// type: 'slider',
// key: 'height',
// title: 'paintings.generate.height',
// min: 256,
// max: 1440,
// initialValue: 768,
// step: 32,
// condition: (painting) => painting.model === 'FLUX.1-Kontext-pro'
// },
{
type: 'slider',
key: 'safetyTolerance',
title: 'paintings.generate.safety_tolerance',
tooltip: 'paintings.generate.safety_tolerance_tip',
min: 0,
max: 6,
initialValue: 6,
condition: (painting) => painting.model === 'FLUX.1-Kontext-pro'
}
],
remix: [
@ -384,5 +419,6 @@ export const DEFAULT_PAINTING: PaintingAction = {
quality: 'auto',
moderation: 'auto',
n: 1,
numberOfImages: 4
numberOfImages: 4,
safetyTolerance: 6
}

View File

@ -109,7 +109,7 @@ const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provid
return (
<Tooltip
destroyTooltipOnHide
destroyOnHidden
title={
isAllInProvider
? t('settings.models.manage.remove_whole_group')

View File

@ -336,7 +336,14 @@ const PopupContainer: React.FC<Props> = ({ providerId, resolve }) => {
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('settings.models.empty')}
style={{ visibility: loadingModels ? 'hidden' : 'visible' }}
style={{
visibility: loadingModels ? 'hidden' : 'visible',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
margin: '0'
}}
/>
) : (
<ManageModelsList

View File

@ -89,15 +89,9 @@ export default class LocalSearchProvider extends BaseWebSearchProvider {
* @returns
*/
protected applyLanguageFilter(query: string, language: string): string {
if (this.provider.id.includes('local-google')) {
if (this.provider.id.includes('local-google') || this.provider.id.includes('local-bing')) {
return `${query} lang:${language.split('-')[0]}`
}
if (this.provider.id.includes('local-bing')) {
return `${query} language:${language}`
}
if (this.provider.id.includes('local-baidu')) {
return `${query} language:${language.split('-')[0]}`
}
return query
}

View File

@ -21,6 +21,17 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
const aiProvider = new AiProvider(provider)
const rerankAiProvider = new AiProvider(rerankProvider)
// get preprocess provider from store instead of base.preprocessProvider
const preprocessProvider = store
.getState()
.preprocess.providers.find((p) => p.id === base.preprocessProvider?.provider.id)
const updatedPreprocessProvider = preprocessProvider
? {
type: 'preprocess' as const,
provider: preprocessProvider
}
: base.preprocessProvider
let host = aiProvider.getBaseURL()
const rerankHost = rerankAiProvider.getBaseURL()
if (provider.type === 'gemini') {
@ -57,7 +68,7 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
apiKey: rerankAiProvider.getApiKey() || 'secret',
baseURL: rerankHost
},
preprocessProvider: base.preprocessProvider,
preprocessProvider: updatedPreprocessProvider,
documentCount: base.documentCount
}
}

View File

@ -1,7 +1,7 @@
import { loggerService } from '@logger'
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import FileManager from '@renderer/services/FileManager'
import { FileMetadata, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
import { FileMetadata, KnowledgeBase, KnowledgeItem, PreprocessProvider, ProcessingStatus } from '@renderer/types'
const logger = loggerService.withContext('Store:Knowledge')
@ -174,6 +174,18 @@ const knowledgeSlice = createSlice({
}
},
syncPreprocessProvider(state, action: PayloadAction<Partial<PreprocessProvider>>) {
const updatedProvider = action.payload
state.bases.forEach((base) => {
if (base.preprocessProvider && base.preprocessProvider.provider.id === updatedProvider.id) {
base.preprocessProvider.provider = {
...base.preprocessProvider.provider,
...updatedProvider
}
}
})
},
updateBaseItemUniqueId(
state,
action: PayloadAction<{ baseId: string; itemId: string; uniqueId: string; uniqueIds: string[] }>
@ -221,7 +233,8 @@ export const {
clearCompletedProcessing,
clearAllProcessing,
updateBaseItemUniqueId,
updateBaseItemIsPreprocessed
updateBaseItemIsPreprocessed,
syncPreprocessProvider
} = knowledgeSlice.actions
export default knowledgeSlice.reducer

View File

@ -56,6 +56,7 @@ export type ReasoningEffortOption = NonNullable<OpenAI.ReasoningEffort> | 'auto'
export type ThinkingOption = ReasoningEffortOption | 'off'
export type ThinkingModelType =
| 'default'
| 'o'
| 'gpt5'
| 'grok'
| 'gemini'
@ -404,6 +405,9 @@ export interface GeneratePainting extends PaintingParams {
background?: string
personGeneration?: GenerateImagesConfig['personGeneration']
numberOfImages?: number
safetyTolerance?: number
width?: number
height?: number
}
export interface EditPainting extends PaintingParams {

View File

@ -163,6 +163,7 @@ export interface AwsBedrockSdkParams {
topP?: number
stream?: boolean
tools?: AwsBedrockSdkTool[]
[key: string]: any // Allow any additional custom parameters
}
export interface AwsBedrockSdkMessageParam {
@ -207,6 +208,22 @@ export interface AwsBedrockSdkMessageParam {
}>
}
export interface AwsBedrockStreamChunk {
type: string
delta?: {
text?: string
toolUse?: { input?: string }
type?: string
thinking?: string
}
index?: number
content_block?: any
usage?: {
inputTokens?: number
outputTokens?: number
}
}
export interface AwsBedrockSdkRawChunk {
contentBlockStart?: {
start?: {
@ -223,6 +240,8 @@ export interface AwsBedrockSdkRawChunk {
toolUse?: {
input?: string
}
type?: string // 支持 'thinking_delta' 等类型
thinking?: string // 支持 thinking 内容
}
contentBlockIndex?: number
}

View File

@ -6,7 +6,8 @@ import {
captureScrollableDivAsBlob,
captureScrollableDivAsDataURL,
compressImage,
convertToBase64
convertToBase64,
makeSvgSizeAdaptive
} from '../image'
// mock 依赖
@ -125,4 +126,79 @@ describe('utils/image', () => {
expect(func).not.toHaveBeenCalled()
})
})
describe('makeSvgSizeAdaptive', () => {
const createSvgElement = (svgString: string): SVGElement => {
const div = document.createElement('div')
div.innerHTML = svgString
const svgElement = div.querySelector<SVGElement>('svg')
if (!svgElement) {
throw new Error(`Test setup error: No <svg> element found in string: "${svgString}"`)
}
return svgElement
}
// Mock document.body.appendChild to avoid errors in jsdom
beforeEach(() => {
vi.spyOn(document.body, 'appendChild').mockImplementation(() => ({}) as Node)
vi.spyOn(document.body, 'removeChild').mockImplementation(() => ({}) as Node)
})
it('should measure and add viewBox/max-width when viewBox is missing', () => {
const svgElement = createSvgElement('<svg width="100pt" height="80pt"></svg>')
// Mock the measurement result on the prototype
const spy = vi
.spyOn(SVGElement.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: 133, height: 106 } as DOMRect)
const result = makeSvgSizeAdaptive(svgElement) as SVGElement
expect(spy).toHaveBeenCalled()
expect(result.getAttribute('viewBox')).toBe('0 0 133 106')
expect(result.style.maxWidth).toBe('133px')
expect(result.getAttribute('width')).toBe('100%')
expect(result.hasAttribute('height')).toBe(false)
spy.mockRestore() // Clean up the prototype spy
})
it('should use width attribute for max-width when viewBox is present', () => {
const svgElement = createSvgElement('<svg viewBox="0 0 50 50" width="100pt" height="80pt"></svg>')
const spy = vi.spyOn(SVGElement.prototype, 'getBoundingClientRect') // Spy to ensure it's NOT called
const result = makeSvgSizeAdaptive(svgElement) as SVGElement
expect(spy).not.toHaveBeenCalled()
expect(result.getAttribute('viewBox')).toBe('0 0 50 50')
expect(result.style.maxWidth).toBe('100pt')
expect(result.getAttribute('width')).toBe('100%')
expect(result.hasAttribute('height')).toBe(false)
spy.mockRestore()
})
it('should handle measurement failure gracefully', () => {
const svgElement = createSvgElement('<svg width="100pt" height="80pt"></svg>')
// Mock a failed measurement
const spy = vi
.spyOn(SVGElement.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: 0, height: 0 } as DOMRect)
const result = makeSvgSizeAdaptive(svgElement) as SVGElement
expect(result.hasAttribute('viewBox')).toBe(false)
expect(result.style.maxWidth).toBe('100pt') // Falls back to width attribute
expect(result.getAttribute('width')).toBe('100%')
spy.mockRestore()
})
it('should return the element unchanged if it is not an SVGElement', () => {
const divElement = document.createElement('div')
const originalOuterHTML = divElement.outerHTML
const result = makeSvgSizeAdaptive(divElement)
expect(result.outerHTML).toBe(originalOuterHTML)
})
})
})

View File

@ -12,10 +12,112 @@ import { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdow
import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
import { markdownToBlocks } from '@tryfabric/martian'
import dayjs from 'dayjs'
import DOMPurify from 'dompurify'
import { appendBlocks } from 'notion-helper' // 引入 notion-helper 的 appendBlocks 函数
const logger = loggerService.withContext('Utils:export')
// 全局的导出状态获取函数
const getExportState = () => store.getState().runtime.export.isExporting
// 全局的导出状态设置函数,使用 dispatch 保障 Redux 状态更新正确
const setExportingState = (isExporting: boolean) => {
store.dispatch(setExportState({ isExporting }))
}
/**
* HTML <br>
*
*
* - br, p, div, span, h1-h6, blockquote
* - strong, b, em, i, u, s, del, mark, small, sup, sub
* - ul, ol, li
* - code, pre, kbd, var, samp
* - table, thead, tbody, tfoot, tr, td, th
*
* @param content
* @returns
*/
const sanitizeReasoningContent = (content: string): string => {
// 先处理换行符转换为 <br>
const contentWithBr = content.replace(/\n/g, '<br>')
// 使用 DOMPurify 清理内容,保留常用的安全标签和属性
const cleanContent = DOMPurify.sanitize(contentWithBr, {
ALLOWED_TAGS: [
// 换行和基础结构
'br',
'p',
'div',
'span',
// 文本格式化
'strong',
'b',
'em',
'i',
'u',
's',
'del',
'mark',
'small',
// 上标下标(数学公式、引用等)
'sup',
'sub',
// 标题
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
// 引用
'blockquote',
// 列表
'ul',
'ol',
'li',
// 代码相关
'code',
'pre',
'kbd',
'var',
'samp',
// 表格AI输出中可能包含表格
'table',
'thead',
'tbody',
'tfoot',
'tr',
'td',
'th',
// 分隔线
'hr'
],
ALLOWED_ATTR: [
// 安全的通用属性
'class',
'title',
'lang',
'dir',
// code 标签的语言属性
'data-language',
// 表格属性
'colspan',
'rowspan',
// 列表属性
'start',
'type'
],
KEEP_CONTENT: true, // 保留被移除标签的文本内容
RETURN_DOM: false,
SANITIZE_DOM: true,
// 允许的协议(预留,虽然目前没有允许链接标签)
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
})
return cleanContent
}
/**
* 使TopicManager确保消息被正确加载
*
@ -33,7 +135,7 @@ async function fetchTopicMessages(topicId: string): Promise<Message[]> {
* @param {number} [length=80] 80
* @returns {string}
*/
export function getTitleFromString(str: string, length: number = 80) {
export function getTitleFromString(str: string, length: number = 80): string {
let title = str.trimStart().split('\n')[0]
if (title.includes('。')) {
@ -57,7 +159,7 @@ export function getTitleFromString(str: string, length: number = 80) {
return title
}
const getRoleText = (role: string, modelName?: string, providerId?: string) => {
const getRoleText = (role: string, modelName?: string, providerId?: string): string => {
const { showModelNameInMarkdown, showModelProviderInMarkdown } = store.getState().settings
if (role === 'user') {
@ -166,7 +268,7 @@ const createBaseMarkdown = (
includeReasoning: boolean = false,
excludeCitations: boolean = false,
normalizeCitations: boolean = true
) => {
): { titleSection: string; reasoningSection: string; contentSection: string; citation: string } => {
const { forceDollarMathInMarkdown } = store.getState().settings
const roleText = getRoleText(message.role, message.model?.name, message.model?.provider)
const titleSection = `## ${roleText}`
@ -180,13 +282,8 @@ const createBaseMarkdown = (
} else if (reasoningContent.startsWith('<think>')) {
reasoningContent = reasoningContent.substring(7)
}
reasoningContent = reasoningContent
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\n/g, '<br>')
// 使用 DOMPurify 安全地处理思维链内容
reasoningContent = sanitizeReasoningContent(reasoningContent)
if (forceDollarMathInMarkdown) {
reasoningContent = convertMathFormula(reasoningContent)
}
@ -216,7 +313,7 @@ const createBaseMarkdown = (
return { titleSection, reasoningSection, contentSection: processedContent, citation }
}
export const messageToMarkdown = (message: Message, excludeCitations?: boolean) => {
export const messageToMarkdown = (message: Message, excludeCitations?: boolean): string => {
const { excludeCitationsInExport, standardizeCitationsInExport } = store.getState().settings
const shouldExcludeCitations = excludeCitations ?? excludeCitationsInExport
const { titleSection, contentSection, citation } = createBaseMarkdown(
@ -228,7 +325,7 @@ export const messageToMarkdown = (message: Message, excludeCitations?: boolean)
return [titleSection, '', contentSection, citation].join('\n')
}
export const messageToMarkdownWithReasoning = (message: Message, excludeCitations?: boolean) => {
export const messageToMarkdownWithReasoning = (message: Message, excludeCitations?: boolean): string => {
const { excludeCitationsInExport, standardizeCitationsInExport } = store.getState().settings
const shouldExcludeCitations = excludeCitations ?? excludeCitationsInExport
const { titleSection, reasoningSection, contentSection, citation } = createBaseMarkdown(
@ -237,10 +334,14 @@ export const messageToMarkdownWithReasoning = (message: Message, excludeCitation
shouldExcludeCitations,
standardizeCitationsInExport
)
return [titleSection, '', reasoningSection + contentSection, citation].join('\n')
return [titleSection, '', reasoningSection, contentSection, citation].join('\n')
}
export const messagesToMarkdown = (messages: Message[], exportReasoning?: boolean, excludeCitations?: boolean) => {
export const messagesToMarkdown = (
messages: Message[],
exportReasoning?: boolean,
excludeCitations?: boolean
): string => {
return messages
.map((message) =>
exportReasoning
@ -266,7 +367,11 @@ const messagesToPlainText = (messages: Message[]): string => {
return messages.map(formatMessageAsPlainText).join('\n\n')
}
export const topicToMarkdown = async (topic: Topic, exportReasoning?: boolean, excludeCitations?: boolean) => {
export const topicToMarkdown = async (
topic: Topic,
exportReasoning?: boolean,
excludeCitations?: boolean
): Promise<string> => {
const topicName = `# ${topic.name}`
const messages = await fetchTopicMessages(topic.id)
@ -290,7 +395,18 @@ export const topicToPlainText = async (topic: Topic): Promise<string> => {
return topicName
}
export const exportTopicAsMarkdown = async (topic: Topic, exportReasoning?: boolean, excludeCitations?: boolean) => {
export const exportTopicAsMarkdown = async (
topic: Topic,
exportReasoning?: boolean,
excludeCitations?: boolean
): Promise<void> => {
if (getExportState()) {
window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'markdown-exporting' })
return
}
setExportingState(true)
const { markdownExportPath } = store.getState().settings
if (!markdownExportPath) {
try {
@ -305,7 +421,9 @@ export const exportTopicAsMarkdown = async (topic: Topic, exportReasoning?: bool
}
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.markdown.export.specified'), key: 'markdown-error' })
logger.debug(error)
logger.error('Failed to export topic as markdown:', error)
} finally {
setExportingState(false)
}
} else {
try {
@ -316,7 +434,9 @@ export const exportTopicAsMarkdown = async (topic: Topic, exportReasoning?: bool
window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' })
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.markdown.export.preconf'), key: 'markdown-error' })
logger.debug(error)
logger.error('Failed to export topic as markdown:', error)
} finally {
setExportingState(false)
}
}
}
@ -325,7 +445,14 @@ export const exportMessageAsMarkdown = async (
message: Message,
exportReasoning?: boolean,
excludeCitations?: boolean
) => {
): Promise<void> => {
if (getExportState()) {
window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'markdown-exporting' })
return
}
setExportingState(true)
const { markdownExportPath } = store.getState().settings
if (!markdownExportPath) {
try {
@ -343,7 +470,9 @@ export const exportMessageAsMarkdown = async (
}
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.markdown.export.specified'), key: 'markdown-error' })
logger.debug(error)
logger.error('Failed to export message as markdown:', error)
} finally {
setExportingState(false)
}
} else {
try {
@ -357,12 +486,14 @@ export const exportMessageAsMarkdown = async (
window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' })
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.markdown.export.preconf'), key: 'markdown-error' })
logger.debug(error)
logger.error('Failed to export message as markdown:', error)
} finally {
setExportingState(false)
}
}
}
const convertMarkdownToNotionBlocks = async (markdown: string) => {
const convertMarkdownToNotionBlocks = async (markdown: string): Promise<any[]> => {
return markdownToBlocks(markdown)
}
@ -371,77 +502,109 @@ const convertThinkingToNotionBlocks = async (thinkingContent: string): Promise<a
return []
}
const thinkingBlocks = [
{
object: 'block',
type: 'toggle',
toggle: {
rich_text: [
{
type: 'text',
text: {
content: '🤔 ' + i18n.t('common.reasoning_content')
},
annotations: {
bold: true
}
}
],
children: [
{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [
{
type: 'text',
text: {
content: thinkingContent
}
}
]
}
}
]
}
}
]
try {
// 预处理思维链内容将HTML的<br>标签转换为真正的换行符
const processedContent = thinkingContent.replace(/<br\s*\/?>/g, '\n')
return thinkingBlocks
// 使用 markdownToBlocks 处理思维链内容
const childrenBlocks = markdownToBlocks(processedContent)
return [
{
object: 'block',
type: 'toggle',
toggle: {
rich_text: [
{
type: 'text',
text: {
content: '🤔 ' + i18n.t('common.reasoning_content')
},
annotations: {
bold: true
}
}
],
children: childrenBlocks
}
}
]
} catch (error) {
logger.error('failed to process reasoning content:', error as Error)
// 发生错误时,回退到简单的段落处理
return [
{
object: 'block',
type: 'toggle',
toggle: {
rich_text: [
{
type: 'text',
text: {
content: '🤔 ' + i18n.t('common.reasoning_content')
},
annotations: {
bold: true
}
}
],
children: [
{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [
{
type: 'text',
text: {
content:
thinkingContent.length > 1800
? thinkingContent.substring(0, 1800) + '...\n' + i18n.t('export.notion.reasoning_truncated')
: thinkingContent
}
}
]
}
}
]
}
}
]
}
}
const executeNotionExport = async (title: string, allBlocks: any[]): Promise<any> => {
const { isExporting } = store.getState().runtime.export
if (isExporting) {
window.message.warning({ content: i18n.t('message.warn.notion.exporting'), key: 'notion-exporting' })
return null
const executeNotionExport = async (title: string, allBlocks: any[]): Promise<boolean> => {
if (getExportState()) {
window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'notion-exporting' })
return false
}
setExportState({ isExporting: true })
title = title.slice(0, 29) + '...'
const { notionDatabaseID, notionApiKey } = store.getState().settings
if (!notionApiKey || !notionDatabaseID) {
window.message.error({ content: i18n.t('message.error.notion.no_api_key'), key: 'notion-no-apikey-error' })
setExportState({ isExporting: false })
return null
return false
}
if (allBlocks.length === 0) {
window.message.error({ content: i18n.t('message.error.notion.export'), key: 'notion-no-content-error' })
return false
}
setExportingState(true)
// 限制标题长度
if (title.length > 32) {
title = title.slice(0, 29) + '...'
}
try {
const notion = new Client({ auth: notionApiKey })
if (allBlocks.length === 0) {
throw new Error('No content to export')
}
window.message.loading({
content: i18n.t('message.loading.notion.preparing'),
key: 'notion-preparing',
duration: 0
})
let mainPageResponse: any = null
let parentBlockId: string | null = null
const response = await notion.pages.create({
parent: { database_id: notionDatabaseID },
@ -451,34 +614,37 @@ const executeNotionExport = async (title: string, allBlocks: any[]): Promise<any
}
}
})
mainPageResponse = response
parentBlockId = response.id
window.message.destroy('notion-preparing')
window.message.loading({
content: i18n.t('message.loading.notion.exporting_progress'),
key: 'notion-exporting',
duration: 0
})
if (allBlocks.length > 0) {
await appendBlocks({
block_id: parentBlockId,
children: allBlocks,
client: notion
})
}
await appendBlocks({
block_id: response.id,
children: allBlocks,
client: notion
})
window.message.destroy('notion-exporting')
window.message.success({ content: i18n.t('message.success.notion.export'), key: 'notion-success' })
return mainPageResponse
return true
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.notion.export'), key: 'notion-export-progress' })
logger.debug(error)
return null
// 清理可能存在的loading消息
window.message.destroy('notion-preparing')
window.message.destroy('notion-exporting')
logger.error('Notion export failed:', error)
window.message.error({ content: i18n.t('message.error.notion.export'), key: 'notion-export-error' })
return false
} finally {
setExportState({ isExporting: false })
setExportingState(false)
}
}
export const exportMessageToNotion = async (title: string, content: string, message?: Message) => {
export const exportMessageToNotion = async (title: string, content: string, message?: Message): Promise<boolean> => {
const { notionExportReasoning } = store.getState().settings
const notionBlocks = await convertMarkdownToNotionBlocks(content)
@ -498,7 +664,7 @@ export const exportMessageToNotion = async (title: string, content: string, mess
return executeNotionExport(title, notionBlocks)
}
export const exportTopicToNotion = async (topic: Topic) => {
export const exportTopicToNotion = async (topic: Topic): Promise<boolean> => {
const { notionExportReasoning, excludeCitationsInExport } = store.getState().settings
const topicMessages = await fetchTopicMessages(topic.id)
@ -532,12 +698,11 @@ export const exportTopicToNotion = async (topic: Topic) => {
return executeNotionExport(topic.name, allBlocks)
}
export const exportMarkdownToYuque = async (title: string, content: string) => {
const { isExporting } = store.getState().runtime.export
export const exportMarkdownToYuque = async (title: string, content: string): Promise<any | null> => {
const { yuqueToken, yuqueRepoId } = store.getState().settings
if (isExporting) {
window.message.warning({ content: i18n.t('message.warn.yuque.exporting'), key: 'yuque-exporting' })
if (getExportState()) {
window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'yuque-exporting' })
return
}
@ -546,7 +711,7 @@ export const exportMarkdownToYuque = async (title: string, content: string) => {
return
}
setExportState({ isExporting: true })
setExportingState(true)
try {
const response = await fetch(`https://www.yuque.com/api/v2/repos/${yuqueRepoId}/docs`, {
@ -602,7 +767,7 @@ export const exportMarkdownToYuque = async (title: string, content: string) => {
})
return null
} finally {
setExportState({ isExporting: false })
setExportingState(false)
}
}
@ -617,7 +782,14 @@ export const exportMarkdownToYuque = async (title: string, content: string) => {
* @param attributes.folder
* @param attributes.vault Vault名称
*/
export const exportMarkdownToObsidian = async (attributes: any) => {
export const exportMarkdownToObsidian = async (attributes: any): Promise<void> => {
if (getExportState()) {
window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'obsidian-exporting' })
return
}
setExportingState(true)
try {
// 从参数获取Vault名称
const obsidianVault = attributes.vault
@ -669,8 +841,10 @@ export const exportMarkdownToObsidian = async (attributes: any) => {
window.open(obsidianUrl)
window.message.success(i18n.t('chat.topics.export.obsidian_export_success'))
} catch (error) {
logger.error('导出到Obsidian失败:', error as Error)
logger.error('Failed to export to Obsidian:', error as Error)
window.message.error(i18n.t('chat.topics.export.obsidian_export_failed'))
} finally {
setExportingState(false)
}
}
@ -719,14 +893,24 @@ function transformObsidianFileName(fileName: string): string {
return sanitized
}
export const exportMarkdownToJoplin = async (title: string, contentOrMessages: string | Message | Message[]) => {
export const exportMarkdownToJoplin = async (
title: string,
contentOrMessages: string | Message | Message[]
): Promise<any | null> => {
const { joplinUrl, joplinToken, joplinExportReasoning, excludeCitationsInExport } = store.getState().settings
if (getExportState()) {
window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'joplin-exporting' })
return
}
if (!joplinUrl || !joplinToken) {
window.message.error(i18n.t('message.error.joplin.no_config'))
return
}
setExportingState(true)
let content: string
if (typeof contentOrMessages === 'string') {
content = contentOrMessages
@ -763,11 +947,13 @@ export const exportMarkdownToJoplin = async (title: string, contentOrMessages: s
}
window.message.success(i18n.t('message.success.joplin.export'))
return
return data
} catch (error: any) {
logger.error('Failed to export to Joplin:', error)
window.message.error(i18n.t('message.error.joplin.export'))
logger.debug(error)
return
return null
} finally {
setExportingState(false)
}
}
@ -776,12 +962,11 @@ export const exportMarkdownToJoplin = async (title: string, contentOrMessages: s
* @param title
* @param content
*/
export const exportMarkdownToSiyuan = async (title: string, content: string) => {
const { isExporting } = store.getState().runtime.export
export const exportMarkdownToSiyuan = async (title: string, content: string): Promise<void> => {
const { siyuanApiUrl, siyuanToken, siyuanBoxId, siyuanRootPath } = store.getState().settings
if (isExporting) {
window.message.warning({ content: i18n.t('message.warn.siyuan.exporting'), key: 'siyuan-exporting' })
if (getExportState()) {
window.message.warning({ content: i18n.t('message.warn.export.exporting'), key: 'siyuan-exporting' })
return
}
@ -790,7 +975,7 @@ export const exportMarkdownToSiyuan = async (title: string, content: string) =>
return
}
setExportState({ isExporting: true })
setExportingState(true)
try {
// test connection
@ -826,13 +1011,13 @@ export const exportMarkdownToSiyuan = async (title: string, content: string) =>
key: 'siyuan-success'
})
} catch (error) {
logger.error('导出到思源笔记失败:', error as Error)
logger.error('Failed to export to Siyuan:', error as Error)
window.message.error({
content: i18n.t('message.error.siyuan.export') + (error instanceof Error ? `: ${error.message}` : ''),
key: 'siyuan-error'
})
} finally {
setExportState({ isExporting: false })
setExportingState(false)
}
}
/**

View File

@ -270,3 +270,81 @@ export const svgToSvgBlob = (svgElement: SVGElement): Blob => {
const svgData = new XMLSerializer().serializeToString(svgElement)
return new Blob([svgData], { type: 'image/svg+xml' })
}
/**
* 使 DOM
* @param element
* @returns
*/
function measureElementSize(element: Element): { width: number; height: number } {
const clone = element.cloneNode(true) as Element
// 检查元素类型并重置样式
if (clone instanceof HTMLElement || clone instanceof SVGElement) {
clone.style.width = ''
clone.style.height = ''
clone.style.position = ''
clone.style.visibility = ''
}
// 创建一个离屏容器
const container = document.createElement('div')
container.style.position = 'absolute'
container.style.top = '-9999px'
container.style.left = '-9999px'
container.style.visibility = 'hidden'
container.appendChild(clone)
document.body.appendChild(container)
// 测量并清理
const rect = clone.getBoundingClientRect()
document.body.removeChild(container)
return { width: rect.width, height: rect.height }
}
/**
* SVG
* - viewBox
* - max-width style
* - width 100%
* - height
*/
export const makeSvgSizeAdaptive = (element: Element): Element => {
// type guard
if (!(element instanceof SVGElement)) {
return element
}
const hasViewBox = element.hasAttribute('viewBox')
const widthStr = element.getAttribute('width')
let measuredWidth: number | undefined
// 如果缺少 viewBox 属性,测量元素尺寸来创建
if (!hasViewBox) {
const renderedSize = measureElementSize(element)
if (renderedSize.width > 0 && renderedSize.height > 0) {
measuredWidth = renderedSize.width
element.setAttribute('viewBox', `0 0 ${renderedSize.width} ${renderedSize.height}`)
}
}
// 设置 max-width
// 优先使用测量得到的宽度值,否则回退到 width 属性值
if (measuredWidth !== undefined) {
element.style.setProperty('max-width', `${measuredWidth}px`)
} else if (widthStr) {
element.style.setProperty('max-width', widthStr)
}
// 调整 width 和 height
element.setAttribute('width', '100%')
element.removeAttribute('height')
// FIXME: 移除 preserveAspectRatio 来避免某些图无法正常预览
element.removeAttribute('preserveAspectRatio')
return element
}