mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 05:11:24 +08:00
Merge branch 'main' into feat/aisdk-package
This commit is contained in:
commit
239c849890
@ -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.')
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -130,7 +130,7 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
|
||||
mouseEnterDelay={0.5}
|
||||
placement="top"
|
||||
// 确保不留下明文
|
||||
destroyTooltipOnHide>
|
||||
destroyOnHidden>
|
||||
<span style={{ cursor: 'help' }}>{maskApiKey(keyStatus.key)}</span>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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;
|
||||
`
|
||||
|
||||
@ -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.')
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": "Βάση Γνώσης",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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()
|
||||
}))
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
76
src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx
Normal file
76
src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
@ -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,
|
||||
|
||||
@ -241,7 +241,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
return (
|
||||
<Popover
|
||||
key={message.id}
|
||||
destroyTooltipOnHide
|
||||
destroyOnHidden
|
||||
content={
|
||||
<MessageWrapper
|
||||
className={classNames([
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -109,7 +109,7 @@ const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provid
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
destroyTooltipOnHide
|
||||
destroyOnHidden
|
||||
title={
|
||||
isAllInProvider
|
||||
? t('settings.models.manage.remove_whole_group')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.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)
|
||||
}
|
||||
}
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user