Merge branch 'main' of https://github.com/CherryHQ/cherry-studio into feat/selection-assistant-mac-version
This commit is contained in:
@@ -107,9 +107,10 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
- 新功能:可选数据保存目录
|
||||
- 快捷助手:支持单独选择助手,支持暂停、上下文、思考过程、流式
|
||||
- 划词助手:系统托盘菜单开关
|
||||
- 翻译:新增 Markdown 预览选项
|
||||
- 新供应商:新增 Vertex AI 服务商
|
||||
- 错误修复和界面优化
|
||||
界面优化:优化多处界面样式,气泡样式改版,自动调整代码预览边栏宽度
|
||||
知识库:修复知识库引用不显示问题,修复部分嵌入模型适配问题
|
||||
备份与恢复:修复超过 2GB 大文件无法恢复问题
|
||||
文件处理:添加 .doc 文件支持
|
||||
划词助手:支持自定义 CSS 样式
|
||||
MCP:基于 Pyodide 实现 Python MCP 服务
|
||||
其他错误修复和优化
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.4.5",
|
||||
"version": "1.4.6",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
|
||||
@@ -15,7 +15,8 @@ export enum IpcChannel {
|
||||
App_SetTrayOnClose = 'app:set-tray-on-close',
|
||||
App_SetTheme = 'app:set-theme',
|
||||
App_SetAutoUpdate = 'app:set-auto-update',
|
||||
App_SetFeedUrl = 'app:set-feed-url',
|
||||
App_SetEnableEarlyAccess = 'app:set-enable-early-access',
|
||||
App_SetUpgradeChannel = 'app:set-upgrade-channel',
|
||||
App_HandleZoomFactor = 'app:handle-zoom-factor',
|
||||
App_Select = 'app:select',
|
||||
App_HasWritePermission = 'app:has-write-permission',
|
||||
|
||||
@@ -406,8 +406,15 @@ export const defaultLanguage = 'en-US'
|
||||
|
||||
export enum FeedUrl {
|
||||
PRODUCTION = 'https://releases.cherry-ai.com',
|
||||
EARLY_ACCESS = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
|
||||
GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
|
||||
}
|
||||
|
||||
export enum UpgradeChannel {
|
||||
LATEST = 'latest', // 最新稳定版本
|
||||
RC = 'rc', // 公测版本
|
||||
BETA = 'beta' // 预览版本
|
||||
}
|
||||
|
||||
export const defaultTimeout = 5 * 1000 * 60
|
||||
|
||||
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']
|
||||
|
||||
+9
-3
@@ -5,7 +5,7 @@ import path from 'node:path'
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences } from 'electron'
|
||||
@@ -141,8 +141,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configManager.setAutoUpdate(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_SetFeedUrl, (_, feedUrl: FeedUrl) => {
|
||||
appUpdater.setFeedUrl(feedUrl)
|
||||
ipcMain.handle(IpcChannel.App_SetEnableEarlyAccess, async (_, isActive: boolean) => {
|
||||
appUpdater.cancelDownload()
|
||||
configManager.setEnableEarlyAccess(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_SetUpgradeChannel, async (_, channel: UpgradeChannel) => {
|
||||
appUpdater.cancelDownload()
|
||||
configManager.setUpgradeChannel(channel)
|
||||
})
|
||||
|
||||
//only for mac
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isWin } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater } from 'electron-updater'
|
||||
@@ -14,6 +14,7 @@ import { configManager } from './ConfigManager'
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
private releaseInfo: UpdateInfo | undefined
|
||||
private cancellationToken: CancellationToken = new CancellationToken()
|
||||
|
||||
constructor(mainWindow: BrowserWindow) {
|
||||
logger.transports.file.level = 'info'
|
||||
@@ -22,9 +23,7 @@ export default class AppUpdater {
|
||||
autoUpdater.forceDevUpdateConfig = !app.isPackaged
|
||||
autoUpdater.autoDownload = configManager.getAutoUpdate()
|
||||
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
|
||||
autoUpdater.setFeedURL(configManager.getFeedUrl())
|
||||
|
||||
// 检测下载错误
|
||||
autoUpdater.on('error', (error) => {
|
||||
// 简单记录错误信息和时间戳
|
||||
logger.error('更新异常', {
|
||||
@@ -64,6 +63,33 @@ export default class AppUpdater {
|
||||
this.autoUpdater = autoUpdater
|
||||
}
|
||||
|
||||
private async _getPreReleaseVersionFromGithub(channel: UpgradeChannel) {
|
||||
try {
|
||||
const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
}
|
||||
})
|
||||
const data = (await responses.json()) as GithubReleaseInfo[]
|
||||
logger.debug('github release data', data)
|
||||
const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => {
|
||||
return item.prerelease && item.tag_name.includes(`-${channel}.`)
|
||||
})
|
||||
|
||||
if (!release) {
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info('release info', release.tag_name)
|
||||
return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}`
|
||||
} catch (error) {
|
||||
logger.error('Failed to get latest not draft version from github:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async _getIpCountry() {
|
||||
try {
|
||||
// add timeout using AbortController
|
||||
@@ -93,9 +119,44 @@ export default class AppUpdater {
|
||||
autoUpdater.autoInstallOnAppQuit = isActive
|
||||
}
|
||||
|
||||
public setFeedUrl(feedUrl: FeedUrl) {
|
||||
autoUpdater.setFeedURL(feedUrl)
|
||||
configManager.setFeedUrl(feedUrl)
|
||||
private async _setFeedUrl() {
|
||||
// disable downgrade and differential download
|
||||
// github and gitcode don't support multiple range download
|
||||
this.autoUpdater.allowDowngrade = false
|
||||
this.autoUpdater.disableDifferentialDownload = true
|
||||
|
||||
if (configManager.getEnableEarlyAccess()) {
|
||||
const channel = configManager.getUpgradeChannel()
|
||||
if (channel === UpgradeChannel.LATEST) {
|
||||
this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST)
|
||||
this.autoUpdater.channel = UpgradeChannel.LATEST
|
||||
return true
|
||||
}
|
||||
|
||||
const preReleaseUrl = await this._getPreReleaseVersionFromGithub(channel)
|
||||
if (preReleaseUrl) {
|
||||
this.autoUpdater.setFeedURL(preReleaseUrl)
|
||||
this.autoUpdater.channel = channel
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// no early access, use latest version
|
||||
this.autoUpdater.channel = 'latest'
|
||||
this.autoUpdater.setFeedURL(FeedUrl.PRODUCTION)
|
||||
|
||||
const ipCountry = await this._getIpCountry()
|
||||
logger.info('ipCountry', ipCountry)
|
||||
if (ipCountry.toLowerCase() !== 'cn') {
|
||||
this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public cancelDownload() {
|
||||
this.cancellationToken.cancel()
|
||||
this.cancellationToken = new CancellationToken()
|
||||
}
|
||||
|
||||
public async checkForUpdates() {
|
||||
@@ -106,10 +167,12 @@ export default class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
const ipCountry = await this._getIpCountry()
|
||||
logger.info('ipCountry', ipCountry)
|
||||
if (ipCountry !== 'CN') {
|
||||
this.autoUpdater.setFeedURL(FeedUrl.EARLY_ACCESS)
|
||||
const isSetFeedUrl = await this._setFeedUrl()
|
||||
if (!isSetFeedUrl) {
|
||||
return {
|
||||
currentVersion: app.getVersion(),
|
||||
updateInfo: null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -117,7 +180,8 @@ export default class AppUpdater {
|
||||
if (update?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
|
||||
// 如果 autoDownload 为 false,则需要再调用下面的函数触发下
|
||||
// do not use await, because it will block the return of this function
|
||||
this.autoUpdater.downloadUpdate()
|
||||
logger.info('downloadUpdate manual by check for updates', this.cancellationToken)
|
||||
this.autoUpdater.downloadUpdate(this.cancellationToken)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -178,7 +242,11 @@ export default class AppUpdater {
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
interface GithubReleaseInfo {
|
||||
draft: boolean
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
}
|
||||
interface ReleaseNoteInfo {
|
||||
readonly version: string
|
||||
readonly note: string | null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defaultLanguage, FeedUrl, ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/constant'
|
||||
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
@@ -16,7 +16,8 @@ export enum ConfigKeys {
|
||||
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
|
||||
EnableQuickAssistant = 'enableQuickAssistant',
|
||||
AutoUpdate = 'autoUpdate',
|
||||
FeedUrl = 'feedUrl',
|
||||
EnableEarlyAccess = 'enableEarlyAccess',
|
||||
UpgradeChannel = 'upgradeChannel',
|
||||
EnableDataCollection = 'enableDataCollection',
|
||||
SelectionAssistantEnabled = 'selectionAssistantEnabled',
|
||||
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
|
||||
@@ -142,12 +143,20 @@ export class ConfigManager {
|
||||
this.set(ConfigKeys.AutoUpdate, value)
|
||||
}
|
||||
|
||||
getFeedUrl(): string {
|
||||
return this.get<string>(ConfigKeys.FeedUrl, FeedUrl.PRODUCTION)
|
||||
getEnableEarlyAccess(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.EnableEarlyAccess, false)
|
||||
}
|
||||
|
||||
setFeedUrl(value: FeedUrl) {
|
||||
this.set(ConfigKeys.FeedUrl, value)
|
||||
setEnableEarlyAccess(value: boolean) {
|
||||
this.set(ConfigKeys.EnableEarlyAccess, value)
|
||||
}
|
||||
|
||||
getUpgradeChannel(): UpgradeChannel {
|
||||
return this.get<UpgradeChannel>(ConfigKeys.UpgradeChannel, UpgradeChannel.LATEST)
|
||||
}
|
||||
|
||||
setUpgradeChannel(value: UpgradeChannel) {
|
||||
this.set(ConfigKeys.UpgradeChannel, value)
|
||||
}
|
||||
|
||||
getEnableDataCollection(): boolean {
|
||||
|
||||
@@ -363,7 +363,7 @@ class FileStorage {
|
||||
public open = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
options: OpenDialogOptions
|
||||
): Promise<{ fileName: string; filePath: string; content: Buffer } | null> => {
|
||||
): Promise<{ fileName: string; filePath: string; content?: Buffer; size: number } | null> => {
|
||||
try {
|
||||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||||
title: '打开文件',
|
||||
@@ -375,8 +375,16 @@ class FileStorage {
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const fileName = filePath.split('/').pop() || ''
|
||||
const content = await readFile(filePath)
|
||||
return { fileName, filePath, content }
|
||||
const stats = await fs.promises.stat(filePath)
|
||||
|
||||
// If the file is less than 2GB, read the content
|
||||
if (stats.size < 2 * 1024 * 1024 * 1024) {
|
||||
const content = await readFile(filePath)
|
||||
return { fileName, filePath, content, size: stats.size }
|
||||
}
|
||||
|
||||
// For large files, only return file information, do not read content
|
||||
return { fileName, filePath, size: stats.size }
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
@@ -23,7 +23,8 @@ const api = {
|
||||
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
|
||||
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
|
||||
setFeedUrl: (feedUrl: FeedUrl) => ipcRenderer.invoke(IpcChannel.App_SetFeedUrl, feedUrl),
|
||||
setEnableEarlyAccess: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableEarlyAccess, isActive),
|
||||
setUpgradeChannel: (channel: UpgradeChannel) => ipcRenderer.invoke(IpcChannel.App_SetUpgradeChannel, channel),
|
||||
setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
handleZoomFactor: (delta: number, reset: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
|
||||
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
mcpToolCallResponseToAnthropicMessage,
|
||||
mcpToolsToAnthropicTools
|
||||
} from '@renderer/utils/mcp-tools'
|
||||
import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
|
||||
import { BaseApiClient } from '../BaseApiClient'
|
||||
@@ -192,7 +192,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
const parts: MessageParam['content'] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: getMainTextContent(message)
|
||||
text: await this.getMessageContent(message)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { getReactStyleFromToken } from '@renderer/utils/shiki'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThemedToken } from 'shiki/core'
|
||||
import styled from 'styled-components'
|
||||
@@ -18,19 +18,20 @@ interface CodePreviewProps {
|
||||
/**
|
||||
* Shiki 流式代码高亮组件
|
||||
*
|
||||
* - 通过 shiki tokenizer 处理流式响应
|
||||
* - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer,不能跳过
|
||||
* - 通过 shiki tokenizer 处理流式响应,高性能
|
||||
* - 进入视口后触发高亮,改善页面内有大量长代码块时的响应
|
||||
* - 并发安全
|
||||
*/
|
||||
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
||||
const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle()
|
||||
const { activeShikiTheme, highlightStreamingCode, cleanupTokenizers } = useCodeStyle()
|
||||
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
|
||||
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
|
||||
const codeContentRef = useRef<HTMLDivElement>(null)
|
||||
const prevCodeLengthRef = useRef(0)
|
||||
const safeCodeStringRef = useRef(children)
|
||||
const highlightQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [isInViewport, setIsInViewport] = useState(false)
|
||||
const codeContainerRef = useRef<HTMLDivElement>(null)
|
||||
const processingRef = useRef(false)
|
||||
const latestRequestedContentRef = useRef<string | null>(null)
|
||||
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
||||
const shikiThemeRef = useRef(activeShikiTheme)
|
||||
|
||||
@@ -45,7 +46,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
|
||||
visible: () => {
|
||||
const scrollHeight = codeContentRef.current?.scrollHeight
|
||||
const scrollHeight = codeContainerRef.current?.scrollHeight
|
||||
return codeCollapsible && (scrollHeight ?? 0) > 350
|
||||
},
|
||||
onClick: () => setIsExpanded((prev) => !prev)
|
||||
@@ -77,81 +78,63 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
setIsUnwrapped(!codeWrappable)
|
||||
}, [codeWrappable])
|
||||
|
||||
// 处理尾部空白字符
|
||||
const safeCodeString = useMemo(() => {
|
||||
return typeof children === 'string' ? children.trimEnd() : ''
|
||||
}, [children])
|
||||
|
||||
const highlightCode = useCallback(async () => {
|
||||
if (!safeCodeString) return
|
||||
const currentContent = typeof children === 'string' ? children.trimEnd() : ''
|
||||
|
||||
if (prevCodeLengthRef.current === safeCodeString.length) return
|
||||
// 记录最新要处理的内容,为了保证最终状态正确
|
||||
latestRequestedContentRef.current = currentContent
|
||||
|
||||
// 捕获当前状态
|
||||
const startPos = prevCodeLengthRef.current
|
||||
const endPos = safeCodeString.length
|
||||
// 如果正在处理,先跳出,等到完成后会检查是否有新内容
|
||||
if (processingRef.current) return
|
||||
|
||||
// 添加到处理队列,确保按顺序处理
|
||||
highlightQueueRef.current = highlightQueueRef.current.then(async () => {
|
||||
// FIXME: 长度有问题,或者破坏了流式内容,需要清理 tokenizer 并使用完整代码重新高亮
|
||||
if (prevCodeLengthRef.current > safeCodeString.length || !safeCodeString.startsWith(safeCodeStringRef.current)) {
|
||||
cleanupTokenizers(callerId)
|
||||
prevCodeLengthRef.current = 0
|
||||
safeCodeStringRef.current = ''
|
||||
processingRef.current = true
|
||||
|
||||
const result = await highlightCodeChunk(safeCodeString, language, callerId)
|
||||
setTokenLines(result.lines)
|
||||
try {
|
||||
// 循环处理,确保会处理最新内容
|
||||
while (latestRequestedContentRef.current !== null) {
|
||||
const contentToProcess = latestRequestedContentRef.current
|
||||
latestRequestedContentRef.current = null // 标记开始处理
|
||||
|
||||
prevCodeLengthRef.current = safeCodeString.length
|
||||
safeCodeStringRef.current = safeCodeString
|
||||
// 传入完整内容,让 ShikiStreamService 检测变化并处理增量高亮
|
||||
const result = await highlightStreamingCode(contentToProcess, language, callerId)
|
||||
|
||||
return
|
||||
// 如有结果,更新 tokenLines
|
||||
if (result.lines.length > 0 || result.recall !== 0) {
|
||||
setTokenLines((prev) => {
|
||||
return result.recall === -1
|
||||
? result.lines
|
||||
: [...prev.slice(0, Math.max(0, prev.length - result.recall)), ...result.lines]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 跳过 race condition,延迟到后续任务
|
||||
if (prevCodeLengthRef.current !== startPos) {
|
||||
return
|
||||
}
|
||||
|
||||
const incrementalCode = safeCodeString.slice(startPos, endPos)
|
||||
const result = await highlightCodeChunk(incrementalCode, language, callerId)
|
||||
setTokenLines((lines) => [...lines.slice(0, Math.max(0, lines.length - result.recall)), ...result.lines])
|
||||
prevCodeLengthRef.current = endPos
|
||||
safeCodeStringRef.current = safeCodeString
|
||||
})
|
||||
}, [callerId, cleanupTokenizers, highlightCodeChunk, language, safeCodeString])
|
||||
} finally {
|
||||
processingRef.current = false
|
||||
}
|
||||
}, [highlightStreamingCode, language, callerId, children])
|
||||
|
||||
// 主题变化时强制重新高亮
|
||||
useEffect(() => {
|
||||
if (shikiThemeRef.current !== activeShikiTheme) {
|
||||
prevCodeLengthRef.current++
|
||||
shikiThemeRef.current = activeShikiTheme
|
||||
cleanupTokenizers(callerId)
|
||||
setTokenLines([])
|
||||
}
|
||||
}, [activeShikiTheme])
|
||||
}, [activeShikiTheme, callerId, cleanupTokenizers])
|
||||
|
||||
// 组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
return () => cleanupTokenizers(callerId)
|
||||
}, [callerId, cleanupTokenizers])
|
||||
|
||||
// 触发代码高亮
|
||||
// - 进入视口后触发第一次高亮
|
||||
// - 内容变化后触发之后的高亮
|
||||
// 视口检测逻辑,进入视口后触发第一次代码高亮
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
if (prevCodeLengthRef.current > 0) {
|
||||
setTimeout(highlightCode, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const codeElement = codeContentRef.current
|
||||
const codeElement = codeContainerRef.current
|
||||
if (!codeElement) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].intersectionRatio > 0 && isMounted) {
|
||||
setTimeout(highlightCode, 0)
|
||||
if (entries[0].intersectionRatio > 0) {
|
||||
setIsInViewport(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
@@ -161,15 +144,18 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
)
|
||||
|
||||
observer.observe(codeElement)
|
||||
return () => observer.disconnect()
|
||||
}, []) // 只执行一次
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [highlightCode])
|
||||
// 触发代码高亮
|
||||
useEffect(() => {
|
||||
if (!isInViewport) return
|
||||
|
||||
setTimeout(highlightCode, 0)
|
||||
}, [isInViewport, highlightCode])
|
||||
|
||||
useEffect(() => {
|
||||
const container = codeContentRef.current
|
||||
const container = codeContainerRef.current
|
||||
if (!container || !codeShowLineNumbers) return
|
||||
|
||||
const digits = Math.max(tokenLines.length.toString().length, 1)
|
||||
@@ -180,7 +166,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
|
||||
return (
|
||||
<ContentContainer
|
||||
ref={codeContentRef}
|
||||
ref={codeContainerRef}
|
||||
$lineNumbers={codeShowLineNumbers}
|
||||
$wrap={codeWrappable && !isUnwrapped}
|
||||
$fadeIn={hasHighlightedCode}
|
||||
|
||||
@@ -90,7 +90,7 @@ const ActionBar = styled.div`
|
||||
background-color: var(--color-background);
|
||||
padding: 4px 4px;
|
||||
border-radius: 99px;
|
||||
box-shadow: 0 0px 5px 0px rgb(128 128 128 / 30%);
|
||||
box-shadow: 0px 2px 8px 0px rgb(128 128 128 / 20%);
|
||||
border: 0.5px solid var(--color-border);
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
@@ -2545,6 +2545,10 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isEmbeddingModel(model)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.provider === 'doubao') {
|
||||
return (
|
||||
REASONING_REGEX.test(model.name) ||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createContext, type PropsWithChildren, use, useCallback, useEffect, use
|
||||
|
||||
interface CodeStyleContextType {
|
||||
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
|
||||
highlightStreamingCode: (code: string, language: string, callerId: string) => Promise<HighlightChunkResult>
|
||||
cleanupTokenizers: (callerId: string) => void
|
||||
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
|
||||
highlightCode: (code: string, language: string) => Promise<string>
|
||||
@@ -22,6 +23,7 @@ interface CodeStyleContextType {
|
||||
|
||||
const defaultCodeStyleContext: CodeStyleContextType = {
|
||||
highlightCodeChunk: async () => ({ lines: [], recall: 0 }),
|
||||
highlightStreamingCode: async () => ({ lines: [], recall: 0 }),
|
||||
cleanupTokenizers: () => {},
|
||||
getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }),
|
||||
highlightCode: async () => '',
|
||||
@@ -114,6 +116,15 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
shikiStreamService.cleanupTokenizers(callerId)
|
||||
}, [])
|
||||
|
||||
// 高亮流式输出的代码
|
||||
const highlightStreamingCode = useCallback(
|
||||
async (fullContent: string, language: string, callerId: string) => {
|
||||
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
return shikiStreamService.highlightStreamingCode(fullContent, normalizedLang, activeShikiTheme, callerId)
|
||||
},
|
||||
[activeShikiTheme, languageMap]
|
||||
)
|
||||
|
||||
// 获取 Shiki pre 标签属性
|
||||
const getShikiPreProperties = useCallback(
|
||||
async (language: string) => {
|
||||
@@ -148,6 +159,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
highlightCodeChunk,
|
||||
highlightStreamingCode,
|
||||
cleanupTokenizers,
|
||||
getShikiPreProperties,
|
||||
highlightCode,
|
||||
@@ -159,6 +171,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
}),
|
||||
[
|
||||
highlightCodeChunk,
|
||||
highlightStreamingCode,
|
||||
cleanupTokenizers,
|
||||
getShikiPreProperties,
|
||||
highlightCode,
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
setTopicPosition,
|
||||
setTray as _setTray,
|
||||
setTrayOnClose,
|
||||
setUpgradeChannel as _setUpgradeChannel,
|
||||
setWindowStyle
|
||||
} from '@renderer/store/settings'
|
||||
import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
|
||||
export function useSettings() {
|
||||
const settings = useAppSelector((state) => state.settings)
|
||||
@@ -62,7 +63,12 @@ export function useSettings() {
|
||||
|
||||
setEarlyAccess(isEarlyAccess: boolean) {
|
||||
dispatch(_setEarlyAccess(isEarlyAccess))
|
||||
window.api.setFeedUrl(isEarlyAccess ? FeedUrl.EARLY_ACCESS : FeedUrl.PRODUCTION)
|
||||
window.api.setEnableEarlyAccess(isEarlyAccess)
|
||||
},
|
||||
|
||||
setUpgradeChannel(channel: UpgradeChannel) {
|
||||
dispatch(_setUpgradeChannel(channel))
|
||||
window.api.setUpgradeChannel(channel)
|
||||
},
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
|
||||
@@ -1385,7 +1385,14 @@
|
||||
"general.image_upload": "Image Upload",
|
||||
"general.auto_check_update.title": "Auto Update",
|
||||
"general.early_access.title": "Early Access",
|
||||
"general.early_access.tooltip": "Enable to use the latest version from GitHub, which may be slower. Please backup your data in advance.",
|
||||
"general.early_access.tooltip": "Updating to test versions cannot be downgraded, there is a risk of data loss, please backup your data in advance",
|
||||
"general.early_access.beta_version": "Beta Version",
|
||||
"general.early_access.rc_version": "RC Version",
|
||||
"general.early_access.latest_version": "Latest Version",
|
||||
"general.early_access.latest_version_tooltip": "github latest version, latest stable version",
|
||||
"general.early_access.version_options": "Version Options",
|
||||
"general.early_access.rc_version_tooltip": "More stable, please backup your data",
|
||||
"general.early_access.beta_version_tooltip": "Latest features but unstable, use with caution",
|
||||
"general.reset.button": "Reset",
|
||||
"general.reset.title": "Data Reset",
|
||||
"general.restore.button": "Restore",
|
||||
|
||||
@@ -1830,7 +1830,14 @@
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.early_access.title": "早期アクセス",
|
||||
"general.early_access.tooltip": "有効にすると、GitHub の最新バージョンを使用します。ダウンロード速度が遅く、不安定な場合があります。データを事前にバックアップしてください。",
|
||||
"general.early_access.tooltip": "更新すると、データが失われる可能性があります。データを事前にバックアップしてください。",
|
||||
"general.early_access.beta_version": "ベータ版",
|
||||
"general.early_access.rc_version": "RC版",
|
||||
"general.early_access.latest_version": "最新版",
|
||||
"general.early_access.latest_version_tooltip": "github latest バージョン, 最新安定版",
|
||||
"general.early_access.version_options": "バージョンオプション",
|
||||
"general.early_access.rc_version_tooltip": "より安定しています。データを事前にバックアップしてください。",
|
||||
"general.early_access.beta_version_tooltip": "最新の機能ですが、不安定な場合があります。使用には注意してください。",
|
||||
"quickPhrase": {
|
||||
"title": "クイックフレーズ",
|
||||
"add": "フレーズを追加",
|
||||
|
||||
@@ -1830,7 +1830,14 @@
|
||||
},
|
||||
"general.auto_check_update.title": "Автоматическое обновление",
|
||||
"general.early_access.title": "Ранний доступ",
|
||||
"general.early_access.tooltip": "Включить для использования последней версии из GitHub, что может быть медленнее и нестабильно. Пожалуйста, сделайте резервную копию данных заранее.",
|
||||
"general.early_access.tooltip": "Обновление до тестовых версий не может быть откачено, существует риск потери данных, пожалуйста, сделайте резервную копию данных заранее",
|
||||
"general.early_access.beta_version": "Бета версия",
|
||||
"general.early_access.rc_version": "RC версия",
|
||||
"general.early_access.latest_version": "Стабильная версия",
|
||||
"general.early_access.latest_version_tooltip": "github latest версия, стабильная версия",
|
||||
"general.early_access.version_options": "Варианты версии",
|
||||
"general.early_access.rc_version_tooltip": "Более стабильно, пожалуйста, сделайте резервную копию данных заранее",
|
||||
"general.early_access.beta_version_tooltip": "Самые последние функции, но нестабильно, используйте с осторожностью",
|
||||
"quickPhrase": {
|
||||
"title": "Быстрые фразы",
|
||||
"add": "Добавить фразу",
|
||||
|
||||
@@ -1385,7 +1385,14 @@
|
||||
"general.image_upload": "图片上传",
|
||||
"general.auto_check_update.title": "自动更新",
|
||||
"general.early_access.title": "抢先体验",
|
||||
"general.early_access.tooltip": "开启后,将使用 GitHub 的最新版本,下载速度可能较慢,请务必提前备份数据",
|
||||
"general.early_access.tooltip": "更新到测试版本不能降级,有数据丢失风险,请务必提前备份数据",
|
||||
"general.early_access.beta_version": "预览版本",
|
||||
"general.early_access.rc_version": "公测版本",
|
||||
"general.early_access.latest_version": "稳定版本",
|
||||
"general.early_access.version_options": "版本选择",
|
||||
"general.early_access.rc_version_tooltip": "相对稳定,请备份数据",
|
||||
"general.early_access.beta_version_tooltip": "功能最新但不稳定,谨慎使用",
|
||||
"general.early_access.latest_version_tooltip": "github latest 版本, 最新稳定版本",
|
||||
"general.reset.button": "重置",
|
||||
"general.reset.title": "重置数据",
|
||||
"general.restore.button": "恢复",
|
||||
|
||||
@@ -1833,7 +1833,14 @@
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.early_access.title": "搶先體驗",
|
||||
"general.early_access.tooltip": "開啟後,將使用 GitHub 的最新版本,下載速度可能較慢,請務必提前備份數據",
|
||||
"general.early_access.tooltip": "更新到測試版本不能降級,有數據丟失風險,請務必提前備份數據",
|
||||
"general.early_access.beta_version": "預覽版本",
|
||||
"general.early_access.rc_version": "公測版本",
|
||||
"general.early_access.latest_version": "穩定版本",
|
||||
"general.early_access.latest_version_tooltip": "github latest 版本, 最新穩定版本",
|
||||
"general.early_access.version_options": "版本選項",
|
||||
"general.early_access.rc_version_tooltip": "相對穩定,請務必提前備份數據",
|
||||
"general.early_access.beta_version_tooltip": "功能最新但不穩定,謹慎使用",
|
||||
"quickPhrase": {
|
||||
"title": "快捷短語",
|
||||
"add": "新增短語",
|
||||
|
||||
@@ -150,7 +150,7 @@ const ThinkingTimeSeconds = memo(
|
||||
)
|
||||
|
||||
const CollapseContainer = styled(Collapse)`
|
||||
margin-bottom: 15px;
|
||||
margin: 15px 0;
|
||||
`
|
||||
|
||||
const MessageTitleLabel = styled.div`
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
margin-bottom: 15px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
|
||||
@@ -326,7 +326,7 @@ const PopoverContent = styled.div`
|
||||
`
|
||||
|
||||
const KnowledgePopoverContent = styled(PopoverContent)`
|
||||
max-width: 800px;
|
||||
max-width: 600px;
|
||||
`
|
||||
|
||||
const PopoverContentItem = styled.div`
|
||||
|
||||
@@ -173,7 +173,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
key={message.id}
|
||||
className={classNames([
|
||||
{
|
||||
[multiModelMessageStyle]: message.role === 'assistant',
|
||||
[multiModelMessageStyle]: message.role === 'assistant' && messages.length > 1,
|
||||
selected: message.id === selectedMessageId
|
||||
}
|
||||
])}>
|
||||
@@ -191,7 +191,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
className={classNames([
|
||||
'in-popover',
|
||||
{
|
||||
[multiModelMessageStyle]: message.role === 'assistant',
|
||||
[multiModelMessageStyle]: message.role === 'assistant' && messages.length > 1,
|
||||
selected: message.id === selectedMessageId
|
||||
}
|
||||
])}>
|
||||
@@ -210,7 +210,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
|
||||
return messageContent
|
||||
},
|
||||
[isGrid, isGrouped, topic, multiModelMessageStyle, selectedMessageId, gridPopoverTrigger]
|
||||
[isGrid, isGrouped, topic, multiModelMessageStyle, messages.length, selectedMessageId, gridPopoverTrigger]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -284,6 +284,9 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
|
||||
&.multi-select-mode {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
.grid {
|
||||
height: auto;
|
||||
}
|
||||
.message {
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
@@ -307,10 +310,12 @@ const MessageWrapper = styled.div<MessageWrapperProps>`
|
||||
&.horizontal {
|
||||
overflow-y: auto;
|
||||
.message {
|
||||
height: 100%;
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.message-content-container {
|
||||
flex: 1;
|
||||
padding-left: 0;
|
||||
max-height: calc(100vh - 350px);
|
||||
overflow-y: auto !important;
|
||||
@@ -328,6 +333,20 @@ const MessageWrapper = styled.div<MessageWrapperProps>`
|
||||
border: 0.5px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
.message {
|
||||
height: 100%;
|
||||
}
|
||||
.message-content-container {
|
||||
overflow: hidden;
|
||||
padding-left: 0;
|
||||
flex: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.MessageFooter {
|
||||
margin-left: 0;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
&.in-popover {
|
||||
height: auto;
|
||||
|
||||
@@ -6,8 +6,10 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setFoldDisplayMode } from '@renderer/store/settings'
|
||||
import type { Model } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { AssistantMessageStatus, type Message } from '@renderer/types/newMessage'
|
||||
import { lightbulbSoftVariants } from '@renderer/utils/motionVariants'
|
||||
import { Avatar, Segmented as AntdSegmented, Tooltip } from 'antd'
|
||||
import { motion } from 'motion/react'
|
||||
import { FC, memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -26,50 +28,62 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
|
||||
const { foldDisplayMode } = useSettings()
|
||||
const isCompact = foldDisplayMode === 'compact'
|
||||
|
||||
const isMessageProcessing = useCallback((message: Message) => {
|
||||
return [
|
||||
AssistantMessageStatus.PENDING,
|
||||
AssistantMessageStatus.PROCESSING,
|
||||
AssistantMessageStatus.SEARCHING
|
||||
].includes(message.status as AssistantMessageStatus)
|
||||
}, [])
|
||||
|
||||
const renderLabel = useCallback(
|
||||
(message: Message) => {
|
||||
const modelTip = message.model?.name
|
||||
const isProcessing = isMessageProcessing(message)
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<Tooltip key={message.id} title={modelTip} mouseEnterDelay={0.5}>
|
||||
<Tooltip key={message.id} title={modelTip} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||
<AvatarWrapper
|
||||
className="avatar-wrapper"
|
||||
$isSelected={message.id === selectMessageId}
|
||||
onClick={() => {
|
||||
setSelectedMessage(message)
|
||||
}}>
|
||||
<ModelAvatar model={message.model as Model} size={22} />
|
||||
<motion.span variants={lightbulbSoftVariants} animate={isProcessing ? 'active' : 'idle'} initial="idle">
|
||||
<ModelAvatar model={message.model as Model} size={22} />
|
||||
</motion.span>
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<SegmentedLabel>
|
||||
<ModelAvatar model={message.model as Model} size={20} />
|
||||
<ModelAvatar className={isProcessing ? 'animation-pulse' : ''} model={message.model as Model} size={20} />
|
||||
<ModelName>{message.model?.name}</ModelName>
|
||||
</SegmentedLabel>
|
||||
)
|
||||
},
|
||||
[isCompact, selectMessageId, setSelectedMessage]
|
||||
[isCompact, isMessageProcessing, selectMessageId, setSelectedMessage]
|
||||
)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<DisplayModeToggle
|
||||
displayMode={foldDisplayMode}
|
||||
onClick={() => dispatch(setFoldDisplayMode(isCompact ? 'expanded' : 'compact'))}>
|
||||
<Tooltip
|
||||
title={
|
||||
isCompact
|
||||
? t(`message.message.multi_model_style.fold.expand`)
|
||||
: t('message.message.multi_model_style.fold.compress')
|
||||
}
|
||||
placement="top">
|
||||
<Tooltip
|
||||
title={
|
||||
isCompact
|
||||
? t(`message.message.multi_model_style.fold.expand`)
|
||||
: t('message.message.multi_model_style.fold.compress')
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
mouseLeaveDelay={0}>
|
||||
<DisplayModeToggle
|
||||
displayMode={foldDisplayMode}
|
||||
onClick={() => dispatch(setFoldDisplayMode(isCompact ? 'expanded' : 'compact'))}>
|
||||
{isCompact ? <ArrowsAltOutlined /> : <ShrinkOutlined />}
|
||||
</Tooltip>
|
||||
</DisplayModeToggle>
|
||||
|
||||
</DisplayModeToggle>
|
||||
</Tooltip>
|
||||
<ModelsContainer $displayMode={foldDisplayMode}>
|
||||
{isCompact ? (
|
||||
/* Compact style display */
|
||||
|
||||
@@ -10,7 +10,8 @@ import { useAppDispatch } from '@renderer/store'
|
||||
import { setUpdateState } from '@renderer/store/runtime'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { compareVersions, runAsyncFunction } from '@renderer/utils'
|
||||
import { Avatar, Button, Progress, Row, Switch, Tag, Tooltip } from 'antd'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import { Bug, FileCheck, Github, Globe, Mail, Rss } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
@@ -25,7 +26,8 @@ const AboutSettings: FC = () => {
|
||||
const [version, setVersion] = useState('')
|
||||
const [isPortable, setIsPortable] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { autoCheckUpdate, setAutoCheckUpdate, earlyAccess, setEarlyAccess } = useSettings()
|
||||
const { autoCheckUpdate, setAutoCheckUpdate, earlyAccess, setEarlyAccess, upgradeChannel, setUpgradeChannel } =
|
||||
useSettings()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { update } = useRuntime()
|
||||
@@ -95,15 +97,65 @@ const AboutSettings: FC = () => {
|
||||
|
||||
const hasNewVersion = update?.info?.version && version ? compareVersions(update.info.version, version) > 0 : false
|
||||
|
||||
const handleUpgradeChannelChange = async (value: UpgradeChannel) => {
|
||||
setUpgradeChannel(value)
|
||||
// Clear update info when switching upgrade channel
|
||||
dispatch(
|
||||
setUpdateState({
|
||||
available: false,
|
||||
info: null,
|
||||
downloaded: false,
|
||||
checking: false,
|
||||
downloading: false,
|
||||
downloadProgress: 0
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Get available test version options based on current version
|
||||
const getAvailableTestChannels = () => {
|
||||
return [
|
||||
{
|
||||
tooltip: t('settings.general.early_access.latest_version_tooltip'),
|
||||
label: t('settings.general.early_access.latest_version'),
|
||||
value: UpgradeChannel.LATEST
|
||||
},
|
||||
{
|
||||
tooltip: t('settings.general.early_access.rc_version_tooltip'),
|
||||
label: t('settings.general.early_access.rc_version'),
|
||||
value: UpgradeChannel.RC
|
||||
},
|
||||
{
|
||||
tooltip: t('settings.general.early_access.beta_version_tooltip'),
|
||||
label: t('settings.general.early_access.beta_version'),
|
||||
value: UpgradeChannel.BETA
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const handlerSetEarlyAccess = (value: boolean) => {
|
||||
setEarlyAccess(value)
|
||||
dispatch(
|
||||
setUpdateState({
|
||||
available: false,
|
||||
info: null,
|
||||
downloaded: false,
|
||||
checking: false,
|
||||
downloading: false,
|
||||
downloadProgress: 0
|
||||
})
|
||||
)
|
||||
if (value === false) setUpgradeChannel(UpgradeChannel.LATEST)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
runAsyncFunction(async () => {
|
||||
const appInfo = await window.api.getAppInfo()
|
||||
setVersion(appInfo.version)
|
||||
setIsPortable(appInfo.isPortable)
|
||||
})
|
||||
setEarlyAccess(earlyAccess)
|
||||
setAutoCheckUpdate(autoCheckUpdate)
|
||||
}, [autoCheckUpdate, earlyAccess, setAutoCheckUpdate, setEarlyAccess])
|
||||
}, [autoCheckUpdate, setAutoCheckUpdate, setEarlyAccess])
|
||||
|
||||
return (
|
||||
<SettingContainer theme={theme}>
|
||||
@@ -167,9 +219,29 @@ const AboutSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.early_access.title')}</SettingRowTitle>
|
||||
<Tooltip title={t('settings.general.early_access.tooltip')} trigger={['hover', 'focus']}>
|
||||
<Switch value={earlyAccess} onChange={(v) => setEarlyAccess(v)} />
|
||||
<Switch value={earlyAccess} onChange={(v) => handlerSetEarlyAccess(v)} />
|
||||
</Tooltip>
|
||||
</SettingRow>
|
||||
{earlyAccess && getAvailableTestChannels().length > 0 && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.early_access.version_options')}</SettingRowTitle>
|
||||
<Radio.Group
|
||||
size="small"
|
||||
buttonStyle="solid"
|
||||
defaultValue={UpgradeChannel.LATEST}
|
||||
value={upgradeChannel}
|
||||
onChange={(e) => handleUpgradeChannelChange(e.target.value)}>
|
||||
{getAvailableTestChannels().map((option) => (
|
||||
<Tooltip key={option.value} title={option.tooltip}>
|
||||
<Radio.Button value={option.value}>{option.label}</Radio.Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingGroup>
|
||||
|
||||
@@ -20,7 +20,7 @@ export type ShikiPreProperties = {
|
||||
* 代码 chunk 高亮结果
|
||||
*
|
||||
* @param lines 所有高亮行(包括稳定和不稳定)
|
||||
* @param recall 需要撤回的行数
|
||||
* @param recall 需要撤回的行数,-1 表示撤回所有行
|
||||
*/
|
||||
export interface HighlightChunkResult {
|
||||
lines: ThemedToken[][]
|
||||
@@ -47,6 +47,13 @@ class ShikiStreamService {
|
||||
}
|
||||
})
|
||||
|
||||
// 缓存每个 callerId 对应的已处理内容
|
||||
private codeCache = new LRUCache<string, string>({
|
||||
max: 100, // 最大缓存数量
|
||||
ttl: 1000 * 60 * 30, // 30分钟过期时间
|
||||
updateAgeOnGet: true
|
||||
})
|
||||
|
||||
// Worker 相关资源
|
||||
private worker: Worker | null = null
|
||||
private workerInitPromise: Promise<void> | null = null
|
||||
@@ -261,6 +268,72 @@ class ShikiStreamService {
|
||||
return hast.children[0].properties as ShikiPreProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮流式输出的代码,调用方传入完整代码内容,得到增量高亮结果。
|
||||
*
|
||||
* - 检测当前内容与上次处理内容的差异。
|
||||
* - 如果是末尾追加,只传输增量部分(此时性能最好,如遇性能问题,考虑检查这里的逻辑)。
|
||||
* - 如果不是追加,重置 tokenizer 并处理完整内容。
|
||||
*
|
||||
* 调用者需要自行处理撤回。
|
||||
* @param code 完整代码内容
|
||||
* @param language 语言
|
||||
* @param theme 主题
|
||||
* @param callerId 调用者ID
|
||||
* @returns 高亮结果,recall 为 -1 表示撤回所有行
|
||||
*/
|
||||
async highlightStreamingCode(
|
||||
code: string,
|
||||
language: string,
|
||||
theme: string,
|
||||
callerId: string
|
||||
): Promise<HighlightChunkResult> {
|
||||
const cacheKey = `${callerId}-${language}-${theme}`
|
||||
const lastContent = this.codeCache.get(cacheKey) || ''
|
||||
|
||||
let isAppend = false
|
||||
|
||||
if (code.length === lastContent.length) {
|
||||
// 内容没有变化,返回空结果
|
||||
if (code === lastContent) {
|
||||
return { lines: [], recall: 0 }
|
||||
}
|
||||
} else if (code.length > lastContent.length) {
|
||||
// 长度增加,可能是追加
|
||||
isAppend = code.startsWith(lastContent)
|
||||
}
|
||||
|
||||
try {
|
||||
let result: HighlightChunkResult
|
||||
|
||||
if (isAppend) {
|
||||
// 流式追加,只传输增量
|
||||
const chunk = code.slice(lastContent.length)
|
||||
result = await this.highlightCodeChunk(chunk, language, theme, callerId)
|
||||
} else {
|
||||
// 非追加变化,重置并处理完整内容
|
||||
this.cleanupTokenizers(callerId)
|
||||
this.codeCache.delete(cacheKey) // 清除缓存
|
||||
|
||||
result = await this.highlightCodeChunk(code, language, theme, callerId)
|
||||
|
||||
// 撤回所有行
|
||||
result = {
|
||||
...result,
|
||||
recall: -1
|
||||
}
|
||||
}
|
||||
|
||||
// 成功处理后更新缓存
|
||||
this.codeCache.set(cacheKey, code)
|
||||
return result
|
||||
} catch (error) {
|
||||
// 处理失败时不更新缓存,保持之前的状态
|
||||
console.error('Failed to highlight streaming code:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮代码 chunk,返回本次高亮的所有 ThemedToken 行
|
||||
*
|
||||
@@ -405,6 +478,13 @@ class ShikiStreamService {
|
||||
})
|
||||
}
|
||||
|
||||
// 清理对应的内容缓存
|
||||
for (const key of this.codeCache.keys()) {
|
||||
if (key.startsWith(`${callerId}-`)) {
|
||||
this.codeCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 再清理主线程中的 tokenizers,移除所有以 callerId 开头的缓存项
|
||||
for (const key of this.tokenizerCache.keys()) {
|
||||
if (key.startsWith(`${callerId}-`)) {
|
||||
@@ -429,6 +509,7 @@ class ShikiStreamService {
|
||||
|
||||
this.workerDegradationCache.clear()
|
||||
this.tokenizerCache.clear()
|
||||
this.codeCache.clear()
|
||||
this.highlighter = null
|
||||
this.workerInitPromise = null
|
||||
this.workerInitRetryCount = 0
|
||||
|
||||
@@ -7,6 +7,7 @@ import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { Assistant, Provider, WebSearchProvider } from '@renderer/types'
|
||||
import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { createMigrate } from 'redux-persist'
|
||||
|
||||
@@ -1627,6 +1628,9 @@ const migrateConfig = {
|
||||
}
|
||||
}
|
||||
})
|
||||
if (state.settings) {
|
||||
state.settings.upgradeChannel = UpgradeChannel.LATEST
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ThemeMode,
|
||||
TranslateLanguageVarious
|
||||
} from '@renderer/types'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
|
||||
import { WebDAVSyncState } from './backup'
|
||||
|
||||
@@ -68,6 +69,7 @@ export interface SettingsState {
|
||||
clickAssistantToShowTopic: boolean
|
||||
autoCheckUpdate: boolean
|
||||
earlyAccess: boolean
|
||||
upgradeChannel: UpgradeChannel
|
||||
renderInputMessageAsMarkdown: boolean
|
||||
// 代码执行
|
||||
codeExecution: {
|
||||
@@ -221,6 +223,7 @@ export const initialState: SettingsState = {
|
||||
clickAssistantToShowTopic: true,
|
||||
autoCheckUpdate: true,
|
||||
earlyAccess: false,
|
||||
upgradeChannel: UpgradeChannel.LATEST,
|
||||
renderInputMessageAsMarkdown: false,
|
||||
codeExecution: {
|
||||
enabled: false,
|
||||
@@ -429,6 +432,9 @@ const settingsSlice = createSlice({
|
||||
setEarlyAccess: (state, action: PayloadAction<boolean>) => {
|
||||
state.earlyAccess = action.payload
|
||||
},
|
||||
setUpgradeChannel: (state, action: PayloadAction<UpgradeChannel>) => {
|
||||
state.upgradeChannel = action.payload
|
||||
},
|
||||
setRenderInputMessageAsMarkdown: (state, action: PayloadAction<boolean>) => {
|
||||
state.renderInputMessageAsMarkdown = action.payload
|
||||
},
|
||||
@@ -725,6 +731,7 @@ export const {
|
||||
setPasteLongTextAsFile,
|
||||
setAutoCheckUpdate,
|
||||
setEarlyAccess,
|
||||
setUpgradeChannel,
|
||||
setRenderInputMessageAsMarkdown,
|
||||
setClickAssistantToShowTopic,
|
||||
setSkipBackupFile,
|
||||
|
||||
@@ -465,10 +465,28 @@ describe('markdown', () => {
|
||||
|
||||
describe('processLatexBrackets', () => {
|
||||
describe('basic LaTeX conversion', () => {
|
||||
it('should convert display math \\[...\\] to $$...$$', () => {
|
||||
it('should convert (inline) display math \\[...\\] to $$...$$', () => {
|
||||
expect(processLatexBrackets('The formula is \\[a+b=c\\]')).toBe('The formula is $$a+b=c$$')
|
||||
})
|
||||
|
||||
it('should convert display math \\[...\\] to $$...$$', () => {
|
||||
const input = `
|
||||
The formula is
|
||||
|
||||
\\[
|
||||
a+b=c
|
||||
\\]
|
||||
`
|
||||
const expected = `
|
||||
The formula is
|
||||
|
||||
$$
|
||||
a+b=c
|
||||
$$
|
||||
`
|
||||
expect(processLatexBrackets(input)).toBe(expected)
|
||||
})
|
||||
|
||||
it('should convert inline math \\(...\\) to $...$', () => {
|
||||
expect(processLatexBrackets('The formula is \\(a+b=c\\)')).toBe('The formula is $a+b=c$')
|
||||
})
|
||||
@@ -611,9 +629,13 @@ const func = \\(x\\) => x * 2;
|
||||
|
||||
Read more in [Section \\[3.2\\]: Advanced Topics](url) and see inline code \`\\[array\\]\`.
|
||||
|
||||
Final thoughts on \\(\\nabla \\cdot \\vec{F} = \\rho\\) and display math:
|
||||
Final thoughts on \\(\\nabla \\cdot \\vec{F} = \\rho\\) in inline math and display math:
|
||||
|
||||
\\[\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}\\]
|
||||
|
||||
\\[
|
||||
\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}
|
||||
\\]
|
||||
`
|
||||
|
||||
const expectedOutput = `
|
||||
@@ -647,9 +669,13 @@ const func = \\(x\\) => x * 2;
|
||||
|
||||
Read more in [Section \\[3.2\\]: Advanced Topics](url) and see inline code \`\\[array\\]\`.
|
||||
|
||||
Final thoughts on $\\nabla \\cdot \\vec{F} = \\rho$ and display math:
|
||||
Final thoughts on $\\nabla \\cdot \\vec{F} = \\rho$ in inline math and display math:
|
||||
|
||||
$$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$
|
||||
|
||||
$$
|
||||
\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}
|
||||
$$
|
||||
`
|
||||
|
||||
expect(processLatexBrackets(complexInput)).toBe(expectedOutput)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { languages } from '@shared/config/languages'
|
||||
import balanced from 'balanced-match'
|
||||
import { default as balanced } from 'balanced-match'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkStringify from 'remark-stringify'
|
||||
import removeMarkdown from 'remove-markdown'
|
||||
@@ -31,7 +31,7 @@ export const findCitationInChildren = (children: any): string => {
|
||||
}
|
||||
|
||||
// 检查是否包含潜在的 LaTeX 模式
|
||||
const containsLatexRegex = /\\\(.*?\\\)|\\\[.*?\\\]|\$.*?\$|\\begin\{equation\}.*?\\end\{equation\}/
|
||||
const containsLatexRegex = /\\\(.*?\\\)|\\\[.*?\\\]|\$.*?\$|\\begin\{equation\}.*?\\end\{equation\}/s
|
||||
|
||||
/**
|
||||
* 转换 LaTeX 公式括号 `\[\]` 和 `\(\)` 为 Markdown 格式 `$$...$$` 和 `$...$`
|
||||
|
||||
@@ -16,3 +16,22 @@ export const lightbulbVariants = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const lightbulbSoftVariants = {
|
||||
active: {
|
||||
opacity: [1, 0.5, 1],
|
||||
transition: {
|
||||
duration: 2,
|
||||
ease: 'easeInOut',
|
||||
times: [0, 0.5, 1],
|
||||
repeat: Infinity
|
||||
}
|
||||
},
|
||||
idle: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: 'easeInOut'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user