Merge branch 'main' of https://github.com/CherryHQ/cherry-studio into feat/selection-assistant-mac-version

This commit is contained in:
fullex
2025-06-26 18:33:44 +08:00
32 changed files with 534 additions and 147 deletions
+7 -6
View File
@@ -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
View File
@@ -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",
+2 -1
View File
@@ -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',
+8 -1
View File
@@ -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
View File
@@ -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
+81 -13
View File
@@ -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
+15 -6
View File
@@ -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 {
+11 -3
View File
@@ -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
+3 -2
View File
@@ -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;
`
+4
View File
@@ -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,
+8 -2
View File
@@ -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) {
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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": "フレーズを追加",
+8 -1
View File
@@ -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": "Добавить фразу",
+8 -1
View File
@@ -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": "恢复",
+8 -1
View File
@@ -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`
@@ -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
+4
View File
@@ -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
+7
View File
@@ -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)
+2 -2
View File
@@ -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 `$$...$$` `$...$`
+19
View File
@@ -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'
}
}
}