mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
Merge remote-tracking branch 'origin/main' into feat/agents-new
This commit is contained in:
commit
e8c94f3584
@ -140,7 +140,7 @@
|
||||
"@langchain/ollama": "^0.2.1",
|
||||
"@langchain/openai": "^0.6.7",
|
||||
"@mistralai/mistralai": "^1.7.5",
|
||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@openrouter/ai-sdk-provider": "^1.1.2",
|
||||
@ -221,6 +221,7 @@
|
||||
"axios": "^1.7.3",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"chardet": "^2.1.0",
|
||||
"check-disk-space": "3.4.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"chokidar": "^4.0.3",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@ -156,8 +156,10 @@ Assistant: The population of Shanghai is 26 million, while Guangzhou has a popul
|
||||
/**
|
||||
* 构建可用工具部分(提取自 Cherry Studio)
|
||||
*/
|
||||
function buildAvailableTools(tools: ToolSet): string {
|
||||
function buildAvailableTools(tools: ToolSet): string | null {
|
||||
const availableTools = Object.keys(tools)
|
||||
if (availableTools.length === 0) return null
|
||||
const result = availableTools
|
||||
.map((toolName: string) => {
|
||||
const tool = tools[toolName]
|
||||
return `
|
||||
@ -172,7 +174,7 @@ function buildAvailableTools(tools: ToolSet): string {
|
||||
})
|
||||
.join('\n')
|
||||
return `<tools>
|
||||
${availableTools}
|
||||
${result}
|
||||
</tools>`
|
||||
}
|
||||
|
||||
@ -181,6 +183,7 @@ ${availableTools}
|
||||
*/
|
||||
function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet): string {
|
||||
const availableTools = buildAvailableTools(tools)
|
||||
if (availableTools === null) return userSystemPrompt
|
||||
|
||||
const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES)
|
||||
.replace('{{ AVAILABLE_TOOLS }}', availableTools)
|
||||
|
||||
@ -35,6 +35,7 @@ export enum IpcChannel {
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
App_LogToMain = 'app:log-to-main',
|
||||
App_SaveData = 'app:save-data',
|
||||
App_GetDiskInfo = 'app:get-disk-info',
|
||||
App_SetFullScreen = 'app:set-full-screen',
|
||||
App_IsFullScreen = 'app:is-full-screen',
|
||||
|
||||
|
||||
6
packages/shared/utils.ts
Normal file
6
packages/shared/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const defaultAppHeaders = () => {
|
||||
return {
|
||||
'HTTP-Referer': 'https://cherry-ai.com',
|
||||
'X-Title': 'Cherry Studio'
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import checkDiskSpace from 'check-disk-space'
|
||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
@ -783,6 +784,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
addStreamMessage(spanId, modelName, context, msg)
|
||||
)
|
||||
|
||||
ipcMain.handle(IpcChannel.App_GetDiskInfo, async (_, directoryPath: string) => {
|
||||
try {
|
||||
const diskSpace = await checkDiskSpace(directoryPath) // { free, size } in bytes
|
||||
logger.debug('disk space', diskSpace)
|
||||
const { free, size } = diskSpace
|
||||
return {
|
||||
free,
|
||||
size
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('check disk space error', error as Error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
// API Server
|
||||
apiServerService.registerIpcHandlers()
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
type StreamableHTTPClientTransportOptions
|
||||
} from '@modelcontextprotocol/sdk/client/streamableHttp'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
|
||||
import { McpError, type Tool as SDKTool } from '@modelcontextprotocol/sdk/types'
|
||||
// Import notification schemas from MCP SDK
|
||||
import {
|
||||
CancelledNotificationSchema,
|
||||
@ -29,6 +30,7 @@ import {
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { MCPProgressEvent } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { defaultAppHeaders } from '@shared/utils'
|
||||
import {
|
||||
BuiltinMCPServerNames,
|
||||
type GetResourceResponse,
|
||||
@ -94,7 +96,7 @@ function getServerLogger(server: MCPServer, extra?: Record<string, any>) {
|
||||
baseUrl: server?.baseUrl,
|
||||
type: server?.type || (server?.command ? 'stdio' : server?.baseUrl ? 'http' : 'inmemory')
|
||||
}
|
||||
return loggerService.withContext('MCPService', { ...base, ...(extra || {}) })
|
||||
return loggerService.withContext('MCPService', { ...base, ...extra })
|
||||
}
|
||||
|
||||
/**
|
||||
@ -193,11 +195,18 @@ class McpService {
|
||||
return existingClient
|
||||
}
|
||||
} catch (error: any) {
|
||||
getServerLogger(server).error(`Error pinging server`, error as Error)
|
||||
getServerLogger(server).error(`Error pinging server ${server.name}`, error as Error)
|
||||
this.clients.delete(serverKey)
|
||||
}
|
||||
}
|
||||
|
||||
const prepareHeaders = () => {
|
||||
return {
|
||||
...defaultAppHeaders(),
|
||||
...server.headers
|
||||
}
|
||||
}
|
||||
|
||||
// Create a promise for the initialization process
|
||||
const initPromise = (async () => {
|
||||
try {
|
||||
@ -235,8 +244,11 @@ class McpService {
|
||||
} else if (server.baseUrl) {
|
||||
if (server.type === 'streamableHttp') {
|
||||
const options: StreamableHTTPClientTransportOptions = {
|
||||
fetch: async (url, init) => {
|
||||
return net.fetch(typeof url === 'string' ? url : url.toString(), init)
|
||||
},
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
headers: prepareHeaders()
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
@ -249,25 +261,11 @@ class McpService {
|
||||
const options: SSEClientTransportOptions = {
|
||||
eventSourceInit: {
|
||||
fetch: async (url, init) => {
|
||||
const headers = { ...(server.headers || {}), ...(init?.headers || {}) }
|
||||
|
||||
// Get tokens from authProvider to make sure using the latest tokens
|
||||
if (authProvider && typeof authProvider.tokens === 'function') {
|
||||
try {
|
||||
const tokens = await authProvider.tokens()
|
||||
if (tokens && tokens.access_token) {
|
||||
headers['Authorization'] = `Bearer ${tokens.access_token}`
|
||||
}
|
||||
} catch (error) {
|
||||
getServerLogger(server).error('Failed to fetch tokens:', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return net.fetch(typeof url === 'string' ? url : url.toString(), { ...init, headers })
|
||||
return net.fetch(typeof url === 'string' ? url : url.toString(), init)
|
||||
}
|
||||
},
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
headers: prepareHeaders()
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
@ -444,9 +442,9 @@ class McpService {
|
||||
|
||||
logger.debug(`Activated server: ${server.name}`)
|
||||
return client
|
||||
} catch (error: any) {
|
||||
getServerLogger(server).error(`Error activating server`, error as Error)
|
||||
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
|
||||
} catch (error) {
|
||||
getServerLogger(server).error(`Error activating server ${server.name}`, error as Error)
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
// Clean up the pending promise when done
|
||||
@ -614,12 +612,11 @@ class McpService {
|
||||
}
|
||||
|
||||
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
|
||||
getServerLogger(server).debug(`Listing tools`)
|
||||
const client = await this.initClient(server)
|
||||
try {
|
||||
const { tools } = await client.listTools()
|
||||
const serverTools: MCPTool[] = []
|
||||
tools.map((tool: any) => {
|
||||
tools.map((tool: SDKTool) => {
|
||||
const serverTool: MCPTool = {
|
||||
...tool,
|
||||
id: buildFunctionCallToolName(server.name, tool.name),
|
||||
@ -628,11 +625,12 @@ class McpService {
|
||||
type: 'mcp'
|
||||
}
|
||||
serverTools.push(serverTool)
|
||||
getServerLogger(server).debug(`Listing tools`, { tool: serverTool })
|
||||
})
|
||||
return serverTools
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
getServerLogger(server).error(`Failed to list tools`, error as Error)
|
||||
return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@ -739,9 +737,9 @@ class McpService {
|
||||
serverId: server.id,
|
||||
serverName: server.name
|
||||
}))
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
// -32601 is the code for the method not found
|
||||
if (error?.code !== -32601) {
|
||||
if (error instanceof McpError && error.code !== -32601) {
|
||||
getServerLogger(server).error(`Failed to list prompts`, error as Error)
|
||||
}
|
||||
return []
|
||||
|
||||
@ -398,11 +398,15 @@ export function validateFileName(fileName: string, platform = process.platform):
|
||||
* @returns 合法的文件名
|
||||
*/
|
||||
export function checkName(fileName: string): string {
|
||||
const validation = validateFileName(fileName)
|
||||
const baseName = path.basename(fileName)
|
||||
const validation = validateFileName(baseName)
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid file name: ${fileName}. ${validation.error}`)
|
||||
// 自动清理非法字符,而不是抛出错误
|
||||
const sanitized = sanitizeFilename(baseName)
|
||||
logger.warn(`File name contains invalid characters, auto-sanitized: "${baseName}" -> "${sanitized}"`)
|
||||
return sanitized
|
||||
}
|
||||
return fileName
|
||||
return baseName
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -44,6 +44,8 @@ export function tracedInvoke(channel: string, spanContext: SpanContext | undefin
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
|
||||
getDiskInfo: (directoryPath: string): Promise<{ free: number; size: number } | null> =>
|
||||
ipcRenderer.invoke(IpcChannel.App_GetDiskInfo, directoryPath),
|
||||
reload: () => ipcRenderer.invoke(IpcChannel.App_Reload),
|
||||
setProxy: (proxy: string | undefined, bypassRules?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
|
||||
|
||||
@ -46,6 +46,7 @@ import { isJSON, parseJSON } from '@renderer/utils'
|
||||
import { addAbortController, removeAbortController } from '@renderer/utils/abortController'
|
||||
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { defaultTimeout } from '@shared/config/constant'
|
||||
import { defaultAppHeaders } from '@shared/utils'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { CompletionsContext } from '../middleware/types'
|
||||
@ -179,8 +180,7 @@ export abstract class BaseApiClient<
|
||||
|
||||
public defaultHeaders() {
|
||||
return {
|
||||
'HTTP-Referer': 'https://cherry-ai.com',
|
||||
'X-Title': 'Cherry Studio',
|
||||
...defaultAppHeaders(),
|
||||
'X-Api-Key': this.apiKey
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 299 KiB |
@ -169,10 +169,6 @@ ul {
|
||||
display: flow-root;
|
||||
}
|
||||
|
||||
.bubble:not(.multi-select-mode) .markdown *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.lucide:not(.lucide-custom) {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
|
||||
179
src/renderer/src/components/HorizontalScrollContainer/index.tsx
Normal file
179
src/renderer/src/components/HorizontalScrollContainer/index.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
/**
|
||||
* 水平滚动容器
|
||||
* @param children 子元素
|
||||
* @param dependencies 依赖项
|
||||
* @param scrollDistance 滚动距离
|
||||
* @param className 类名
|
||||
* @param gap 间距
|
||||
* @param expandable 是否可展开
|
||||
*/
|
||||
export interface HorizontalScrollContainerProps {
|
||||
children: React.ReactNode
|
||||
dependencies?: readonly unknown[]
|
||||
scrollDistance?: number
|
||||
className?: string
|
||||
gap?: string
|
||||
expandable?: boolean
|
||||
}
|
||||
|
||||
const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
|
||||
children,
|
||||
dependencies = [],
|
||||
scrollDistance = 200,
|
||||
className,
|
||||
gap = '8px',
|
||||
expandable = false
|
||||
}) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScroll, setCanScroll] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isScrolledToEnd, setIsScrolledToEnd] = useState(false)
|
||||
|
||||
const handleScrollRight = (event: React.MouseEvent) => {
|
||||
scrollRef.current?.scrollBy({ left: scrollDistance, behavior: 'smooth' })
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const handleContainerClick = (e: React.MouseEvent) => {
|
||||
if (expandable) {
|
||||
// 确保不是点击了其他交互元素(如 tag 的关闭按钮)
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('[data-no-expand]')) {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkScrollability = () => {
|
||||
const scrollElement = scrollRef.current
|
||||
if (scrollElement) {
|
||||
const parentElement = scrollElement.parentElement
|
||||
const availableWidth = parentElement ? parentElement.clientWidth : scrollElement.clientWidth
|
||||
|
||||
// 确保容器不会超出可用宽度
|
||||
const canScrollValue = scrollElement.scrollWidth > Math.min(availableWidth, scrollElement.clientWidth)
|
||||
setCanScroll(canScrollValue)
|
||||
|
||||
// 检查是否滚动到最右侧
|
||||
if (canScrollValue) {
|
||||
const isAtEnd = Math.abs(scrollElement.scrollLeft + scrollElement.clientWidth - scrollElement.scrollWidth) <= 1
|
||||
setIsScrolledToEnd(isAtEnd)
|
||||
} else {
|
||||
setIsScrolledToEnd(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current
|
||||
if (!scrollElement) return
|
||||
|
||||
checkScrollability()
|
||||
|
||||
const handleScroll = () => {
|
||||
checkScrollability()
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkScrollability)
|
||||
resizeObserver.observe(scrollElement)
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll)
|
||||
window.addEventListener('resize', checkScrollability)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
scrollElement.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', checkScrollability)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, dependencies)
|
||||
|
||||
return (
|
||||
<Container
|
||||
className={className}
|
||||
$expandable={expandable}
|
||||
$disableHoverButton={isScrolledToEnd}
|
||||
onClick={expandable ? handleContainerClick : undefined}>
|
||||
<ScrollContent ref={scrollRef} $gap={gap} $isExpanded={isExpanded} $expandable={expandable}>
|
||||
{children}
|
||||
</ScrollContent>
|
||||
{canScroll && !isExpanded && !isScrolledToEnd && (
|
||||
<ScrollButton onClick={handleScrollRight} className="scroll-right-button">
|
||||
<ChevronRight size={14} />
|
||||
</ScrollButton>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div<{ $expandable?: boolean; $disableHoverButton?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
cursor: ${(props) => (props.$expandable ? 'pointer' : 'default')};
|
||||
|
||||
${(props) =>
|
||||
!props.$disableHoverButton &&
|
||||
`
|
||||
&:hover {
|
||||
.scroll-right-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
const ScrollContent = styled(Scrollbar)<{
|
||||
$gap: string
|
||||
$isExpanded?: boolean
|
||||
$expandable?: boolean
|
||||
}>`
|
||||
display: flex;
|
||||
overflow-x: ${(props) => (props.$expandable && props.$isExpanded ? 'hidden' : 'auto')};
|
||||
overflow-y: hidden;
|
||||
white-space: ${(props) => (props.$expandable && props.$isExpanded ? 'normal' : 'nowrap')};
|
||||
gap: ${(props) => props.$gap};
|
||||
flex-wrap: ${(props) => (props.$expandable && props.$isExpanded ? 'wrap' : 'nowrap')};
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const ScrollButton = styled.div`
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
background: var(--color-background);
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-text-2);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-list-item);
|
||||
}
|
||||
`
|
||||
|
||||
export default HorizontalScrollContainer
|
||||
@ -135,6 +135,7 @@ const Container = styled.div`
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
min-height: 85px;
|
||||
`
|
||||
|
||||
const IconContainer = styled.div`
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import CherryLogo from '@renderer/assets/images/banner.png'
|
||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser'
|
||||
import { Skeleton, Typography } from 'antd'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
const { Title, Paragraph } = Typography
|
||||
|
||||
@ -11,6 +10,8 @@ type Props = {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const IMAGE_HEIGHT = '9rem' // equals h-36
|
||||
|
||||
export const OGCard = ({ link, show }: Props) => {
|
||||
const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const
|
||||
const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph)
|
||||
@ -32,6 +33,14 @@ export const OGCard = ({ link, show }: Props) => {
|
||||
}
|
||||
}, [parseMetadata, isLoading, show])
|
||||
|
||||
const GeneratedGraph = useCallback(() => {
|
||||
return (
|
||||
<div className="flex h-36 items-center justify-center bg-accent p-4">
|
||||
<h2 className="text-2xl font-bold">{metadata['og:title'] || hostname}</h2>
|
||||
</div>
|
||||
)
|
||||
}, [hostname, metadata])
|
||||
|
||||
if (isLoading) {
|
||||
return <CardSkeleton />
|
||||
}
|
||||
@ -45,7 +54,7 @@ export const OGCard = ({ link, show }: Props) => {
|
||||
)}
|
||||
{!hasImage && (
|
||||
<PreviewImageContainer>
|
||||
<PreviewImage src={CherryLogo} alt={'no image'} />
|
||||
<GeneratedGraph />
|
||||
</PreviewImageContainer>
|
||||
)}
|
||||
|
||||
@ -113,8 +122,8 @@ const PreviewContainer = styled.div<{ hasImage?: boolean }>`
|
||||
|
||||
const PreviewImageContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
height: ${IMAGE_HEIGHT};
|
||||
min-height: ${IMAGE_HEIGHT};
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
@ -128,7 +137,7 @@ const PreviewContent = styled.div`
|
||||
|
||||
const PreviewImage = styled.img`
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
height: ${IMAGE_HEIGHT};
|
||||
object-fit: cover;
|
||||
`
|
||||
|
||||
|
||||
@ -32,6 +32,11 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
|
||||
}, [])
|
||||
|
||||
// 添加更新整个列表的方法
|
||||
const updateList = useCallback((newList: QuickPanelListItem[]) => {
|
||||
setList(newList)
|
||||
}, [])
|
||||
|
||||
const open = useCallback((options: QuickPanelOpenOptions) => {
|
||||
if (clearTimer.current) {
|
||||
clearTimeout(clearTimer.current)
|
||||
@ -56,7 +61,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
const close = useCallback(
|
||||
(action?: QuickPanelCloseAction, searchText?: string) => {
|
||||
setIsVisible(false)
|
||||
onClose?.({ symbol, action, triggerInfo, searchText, item: {} as QuickPanelListItem, multiple: false })
|
||||
onClose?.({ action, searchText, item: {} as QuickPanelListItem, context: this })
|
||||
|
||||
clearTimer.current = setTimeout(() => {
|
||||
setList([])
|
||||
@ -68,7 +73,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
setTriggerInfo(undefined)
|
||||
}, 200)
|
||||
},
|
||||
[onClose, symbol, triggerInfo]
|
||||
[onClose]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -85,6 +90,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
open,
|
||||
close,
|
||||
updateItemSelection,
|
||||
updateList,
|
||||
|
||||
isVisible,
|
||||
symbol,
|
||||
@ -103,6 +109,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
open,
|
||||
close,
|
||||
updateItemSelection,
|
||||
updateList,
|
||||
isVisible,
|
||||
symbol,
|
||||
list,
|
||||
|
||||
@ -8,13 +8,10 @@ export type QuickPanelTriggerInfo = {
|
||||
}
|
||||
|
||||
export type QuickPanelCallBackOptions = {
|
||||
symbol: string
|
||||
context: QuickPanelContextType
|
||||
action: QuickPanelCloseAction
|
||||
item: QuickPanelListItem
|
||||
searchText?: string
|
||||
/** 是否处于多选状态 */
|
||||
multiple?: boolean
|
||||
triggerInfo?: QuickPanelTriggerInfo
|
||||
}
|
||||
|
||||
export type QuickPanelOpenOptions = {
|
||||
@ -68,6 +65,7 @@ export interface QuickPanelContextType {
|
||||
readonly open: (options: QuickPanelOpenOptions) => void
|
||||
readonly close: (action?: QuickPanelCloseAction, searchText?: string) => void
|
||||
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
|
||||
readonly updateList: (newList: QuickPanelListItem[]) => void
|
||||
readonly isVisible: boolean
|
||||
readonly symbol: string
|
||||
readonly list: QuickPanelListItem[]
|
||||
|
||||
@ -222,11 +222,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
// 创建更新后的item对象用于回调
|
||||
const updatedItem = { ...item, isSelected: newSelectedState }
|
||||
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
|
||||
symbol: ctx.symbol,
|
||||
context: ctx,
|
||||
action,
|
||||
item: updatedItem,
|
||||
searchText: searchText,
|
||||
multiple: ctx.multiple
|
||||
searchText: searchText
|
||||
}
|
||||
|
||||
ctx.beforeAction?.(quickPanelCallBackOptions)
|
||||
@ -236,11 +235,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
}
|
||||
|
||||
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
|
||||
symbol: ctx.symbol,
|
||||
context: ctx,
|
||||
action,
|
||||
item,
|
||||
searchText: searchText,
|
||||
multiple: ctx.multiple
|
||||
searchText: searchText
|
||||
}
|
||||
|
||||
ctx.beforeAction?.(quickPanelCallBackOptions)
|
||||
|
||||
@ -2,12 +2,12 @@ import { throttle } from 'lodash'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
|
||||
export interface ScrollbarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
|
||||
ref?: React.Ref<HTMLDivElement | null>
|
||||
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
|
||||
}
|
||||
|
||||
const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
|
||||
const Scrollbar: FC<ScrollbarProps> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
|
||||
const [isScrolling, setIsScrolling] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
@ -14,9 +14,8 @@ import type { Tab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { Tooltip } from 'antd'
|
||||
import {
|
||||
ChevronRight,
|
||||
FileSearch,
|
||||
Folder,
|
||||
Hammer,
|
||||
@ -33,7 +32,7 @@ import {
|
||||
Terminal,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
@ -98,8 +97,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { minapps } = useMinapps()
|
||||
const { t } = useTranslation()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScroll, setCanScroll] = useState(false)
|
||||
|
||||
const getTabId = (path: string): string => {
|
||||
if (path === '/') return 'home'
|
||||
@ -175,31 +172,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
navigate(tab.path)
|
||||
}
|
||||
|
||||
const handleScrollRight = () => {
|
||||
scrollRef.current?.scrollBy({ left: 200, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current
|
||||
if (!scrollElement) return
|
||||
|
||||
const checkScrollability = () => {
|
||||
setCanScroll(scrollElement.scrollWidth > scrollElement.clientWidth)
|
||||
}
|
||||
|
||||
checkScrollability()
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkScrollability)
|
||||
resizeObserver.observe(scrollElement)
|
||||
|
||||
window.addEventListener('resize', checkScrollability)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener('resize', checkScrollability)
|
||||
}
|
||||
}, [tabs])
|
||||
|
||||
const visibleTabs = useMemo(() => tabs.filter((tab) => !specialTabs.includes(tab.id)), [tabs])
|
||||
|
||||
const { onSortEnd } = useDndReorder<Tab>({
|
||||
@ -212,46 +184,39 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
return (
|
||||
<Container>
|
||||
<TabsBar $isFullscreen={isFullscreen}>
|
||||
<TabsArea>
|
||||
<TabsScroll ref={scrollRef}>
|
||||
<Sortable
|
||||
items={visibleTabs}
|
||||
itemKey="id"
|
||||
layout="list"
|
||||
horizontal
|
||||
gap={'6px'}
|
||||
onSortEnd={onSortEnd}
|
||||
className="tabs-sortable"
|
||||
renderItem={(tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
|
||||
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
<CloseButton
|
||||
className="close-button"
|
||||
data-no-dnd
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}}>
|
||||
<X size={12} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</Tab>
|
||||
)}
|
||||
/>
|
||||
</TabsScroll>
|
||||
{canScroll && (
|
||||
<ScrollButton onClick={handleScrollRight} className="scroll-right-button" shape="circle" size="small">
|
||||
<ChevronRight size={16} />
|
||||
</ScrollButton>
|
||||
)}
|
||||
<HorizontalScrollContainer dependencies={[tabs]} gap="6px" className="tab-scroll-container">
|
||||
<Sortable
|
||||
items={visibleTabs}
|
||||
itemKey="id"
|
||||
layout="list"
|
||||
horizontal
|
||||
gap={'6px'}
|
||||
onSortEnd={onSortEnd}
|
||||
className="tabs-sortable"
|
||||
renderItem={(tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
|
||||
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
<CloseButton
|
||||
className="close-button"
|
||||
data-no-dnd
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}}>
|
||||
<X size={12} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</Tab>
|
||||
)}
|
||||
/>
|
||||
<AddTabButton onClick={handleAddTab} className={classNames({ active: activeTabId === 'launchpad' })}>
|
||||
<PlusOutlined />
|
||||
</AddTabButton>
|
||||
</TabsArea>
|
||||
</HorizontalScrollContainer>
|
||||
<RightButtonsContainer>
|
||||
<Tooltip
|
||||
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
|
||||
@ -307,36 +272,16 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
z-index: 1;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
`
|
||||
|
||||
const TabsArea = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
padding-right: 2rem;
|
||||
position: relative;
|
||||
.tab-scroll-container {
|
||||
-webkit-app-region: drag;
|
||||
|
||||
-webkit-app-region: drag;
|
||||
|
||||
> * {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.scroll-right-button {
|
||||
opacity: 1;
|
||||
> * {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TabsScroll = styled(Scrollbar)`
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const Tab = styled.div<{ active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -414,22 +359,6 @@ const AddTabButton = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const ScrollButton = styled(Button)`
|
||||
position: absolute;
|
||||
right: 4rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
`
|
||||
|
||||
const RightButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -12,6 +12,7 @@ import { handleSaveData } from '@renderer/store'
|
||||
import { selectMemoryConfig } from '@renderer/store/memory'
|
||||
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
|
||||
import { delay, runAsyncFunction } from '@renderer/utils'
|
||||
import { checkDataLimit } from '@renderer/utils'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
@ -20,7 +21,7 @@ import { useEffect } from 'react'
|
||||
import { useDefaultModel } from './useAssistant'
|
||||
import useFullScreenNotice from './useFullScreenNotice'
|
||||
import { useRuntime } from './useRuntime'
|
||||
import { useSettings } from './useSettings'
|
||||
import { useNavbarPosition, useSettings } from './useSettings'
|
||||
import useUpdateHandler from './useUpdateHandler'
|
||||
|
||||
const logger = loggerService.withContext('useAppInit')
|
||||
@ -37,6 +38,7 @@ export function useAppInit() {
|
||||
customCss,
|
||||
enableDataCollection
|
||||
} = useSettings()
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
const { minappShow } = useRuntime()
|
||||
const { setDefaultModel, setQuickModel, setTranslateModel } = useDefaultModel()
|
||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||
@ -102,14 +104,14 @@ export function useAppInit() {
|
||||
useEffect(() => {
|
||||
const transparentWindow = windowStyle === 'transparent' && isMac && !minappShow
|
||||
|
||||
if (minappShow) {
|
||||
if (minappShow && isTopNavbar) {
|
||||
window.root.style.background =
|
||||
windowStyle === 'transparent' && isMac ? 'var(--color-background)' : 'var(--navbar-background)'
|
||||
return
|
||||
}
|
||||
|
||||
window.root.style.background = transparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
|
||||
}, [windowStyle, minappShow, theme])
|
||||
}, [windowStyle, minappShow, theme, isTopNavbar])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalAi) {
|
||||
@ -158,4 +160,8 @@ export function useAppInit() {
|
||||
logger.error('Failed to update memory config:', error)
|
||||
})
|
||||
}, [memoryConfig])
|
||||
|
||||
useEffect(() => {
|
||||
checkDataLimit()
|
||||
}, [])
|
||||
}
|
||||
|
||||
@ -1708,7 +1708,7 @@
|
||||
"delete_confirm": "Are you sure you want to delete this {{type}}?",
|
||||
"delete_folder_confirm": "Are you sure you want to delete the folder \"{{name}}\" and all of its contents?",
|
||||
"delete_note_confirm": "Are you sure you want to delete the note \"{{name}}\"?",
|
||||
"drop_markdown_hint": "Drop markdown files here to import",
|
||||
"drop_markdown_hint": "Drop .md files or folders here to import",
|
||||
"empty": "No notes available yet",
|
||||
"expand": "unfold",
|
||||
"export_failed": "Failed to export to knowledge base",
|
||||
@ -1719,8 +1719,7 @@
|
||||
"new_note": "Create a new note",
|
||||
"no_content_to_copy": "No content to copy",
|
||||
"no_file_selected": "Please select the file to upload",
|
||||
"only_markdown": "Only Markdown files are supported",
|
||||
"only_one_file_allowed": "Only one file can be uploaded",
|
||||
"no_valid_files": "No valid file was uploaded",
|
||||
"open_folder": "Open an external folder",
|
||||
"open_outside": "Open from external",
|
||||
"rename": "Rename",
|
||||
@ -1782,7 +1781,7 @@
|
||||
"sort_updated_asc": "Update time (oldest first)",
|
||||
"sort_updated_desc": "Update time (newest first)",
|
||||
"sort_z2a": "File name (Z-A)",
|
||||
"star": "Favorite",
|
||||
"star": "Favorite note",
|
||||
"starred_notes": "Collected notes",
|
||||
"title": "Notes",
|
||||
"unsaved_changes": "You have unsaved content, are you sure you want to leave?",
|
||||
@ -2651,6 +2650,10 @@
|
||||
"url": "Joplin Web Clipper Service URL",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
},
|
||||
"limit": {
|
||||
"appDataDiskQuota": "Disk Space Warning",
|
||||
"appDataDiskQuotaDescription": "Data directory space is almost full, please clear disk space, otherwise data will be lost"
|
||||
},
|
||||
"local": {
|
||||
"autoSync": {
|
||||
"label": "Auto Backup",
|
||||
|
||||
@ -132,6 +132,7 @@
|
||||
},
|
||||
"title": "API 服务器"
|
||||
},
|
||||
|
||||
"assistants": {
|
||||
"abbr": "助手",
|
||||
"clear": {
|
||||
@ -1708,7 +1709,7 @@
|
||||
"delete_confirm": "确定要删除这个{{type}}吗?",
|
||||
"delete_folder_confirm": "确定要删除文件夹 \"{{name}}\" 及其所有内容吗?",
|
||||
"delete_note_confirm": "确定要删除笔记 \"{{name}}\" 吗?",
|
||||
"drop_markdown_hint": "拖拽 Markdown 文件到此处导入",
|
||||
"drop_markdown_hint": "拖拽 .md 文件或目录到此处导入",
|
||||
"empty": "暂无笔记",
|
||||
"expand": "展开",
|
||||
"export_failed": "导出到知识库失败",
|
||||
@ -1719,8 +1720,7 @@
|
||||
"new_note": "新建笔记",
|
||||
"no_content_to_copy": "没有内容可复制",
|
||||
"no_file_selected": "请选择要上传的文件",
|
||||
"only_markdown": "仅支持 Markdown 格式",
|
||||
"only_one_file_allowed": "只能上传一个文件",
|
||||
"no_valid_files": "没有上传有效的文件",
|
||||
"open_folder": "打开外部文件夹",
|
||||
"open_outside": "从外部打开",
|
||||
"rename": "重命名",
|
||||
@ -1782,7 +1782,7 @@
|
||||
"sort_updated_asc": "更新时间(从旧到新)",
|
||||
"sort_updated_desc": "更新时间(从新到旧)",
|
||||
"sort_z2a": "文件名(Z-A)",
|
||||
"star": "收藏",
|
||||
"star": "收藏笔记",
|
||||
"starred_notes": "收藏的笔记",
|
||||
"title": "笔记",
|
||||
"unsaved_changes": "你有未保存的内容,确定要离开吗?",
|
||||
@ -2651,6 +2651,10 @@
|
||||
"url": "Joplin 剪裁服务监听 URL",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
},
|
||||
"limit": {
|
||||
"appDataDiskQuota": "磁盘空间警告",
|
||||
"appDataDiskQuotaDescription": "数据目录空间即将用尽, 请清理磁盘空间, 否则会丢失数据"
|
||||
},
|
||||
"local": {
|
||||
"autoSync": {
|
||||
"label": "自动备份",
|
||||
|
||||
@ -1708,7 +1708,7 @@
|
||||
"delete_confirm": "確定要刪除此 {{type}} 嗎?",
|
||||
"delete_folder_confirm": "確定要刪除資料夾 \"{{name}}\" 及其所有內容嗎?",
|
||||
"delete_note_confirm": "確定要刪除筆記 \"{{name}}\" 嗎?",
|
||||
"drop_markdown_hint": "拖拽 Markdown 文件到此處導入",
|
||||
"drop_markdown_hint": "拖拽 .md 文件或資料夾到此處導入",
|
||||
"empty": "暫無筆記",
|
||||
"expand": "展開",
|
||||
"export_failed": "匯出至知識庫失敗",
|
||||
@ -1719,8 +1719,7 @@
|
||||
"new_note": "新建筆記",
|
||||
"no_content_to_copy": "沒有內容可複制",
|
||||
"no_file_selected": "請選擇要上傳的文件",
|
||||
"only_markdown": "僅支援 Markdown 格式",
|
||||
"only_one_file_allowed": "只能上傳一個文件",
|
||||
"no_valid_files": "沒有上傳有效的檔案",
|
||||
"open_folder": "打開外部文件夾",
|
||||
"open_outside": "從外部打開",
|
||||
"rename": "重命名",
|
||||
@ -1782,7 +1781,7 @@
|
||||
"sort_updated_asc": "更新時間(從舊到新)",
|
||||
"sort_updated_desc": "更新時間(從新到舊)",
|
||||
"sort_z2a": "文件名(Z-A)",
|
||||
"star": "收藏",
|
||||
"star": "收藏筆記",
|
||||
"starred_notes": "收藏的筆記",
|
||||
"title": "筆記",
|
||||
"unsaved_changes": "你有未儲存的內容,確定要離開嗎?",
|
||||
@ -2651,6 +2650,10 @@
|
||||
"url": "Joplin 剪輯服務 URL",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
},
|
||||
"limit": {
|
||||
"appDataDiskQuota": "磁碟空間警告",
|
||||
"appDataDiskQuotaDescription": "資料目錄空間即將用盡, 請清理磁碟空間, 否則會丟失數據"
|
||||
},
|
||||
"local": {
|
||||
"autoSync": {
|
||||
"label": "自動備份",
|
||||
|
||||
@ -538,6 +538,10 @@
|
||||
"tip": "Στη γραμμή εργαλείων των εκτελέσιμων blocks κώδικα θα εμφανίζεται το κουμπί εκτέλεσης· προσέξτε να μην εκτελέσετε επικίνδυνο κώδικα!",
|
||||
"title": "Εκτέλεση Κώδικα"
|
||||
},
|
||||
"code_fancy_block": {
|
||||
"label": "Μπλοκ κώδικα με στυλ",
|
||||
"tip": "Χρησιμοποιήστε πιο όμορφο στιλ μπλοκ κώδικα, για παράδειγμα κάρτες HTML"
|
||||
},
|
||||
"code_image_tools": {
|
||||
"label": "Ενεργοποίηση εργαλείου προεπισκόπησης",
|
||||
"tip": "Ενεργοποίηση εργαλείου προεπισκόπησης για εικόνες που αποδίδονται από blocks κώδικα όπως το mermaid"
|
||||
@ -1704,7 +1708,7 @@
|
||||
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το {{type}};",
|
||||
"delete_folder_confirm": "Θέλετε να διαγράψετε τον φάκελο «{{name}}» και όλο το περιεχόμενό του;",
|
||||
"delete_note_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τη σημείωση \"{{name}}\";;",
|
||||
"drop_markdown_hint": "Σύρετε το αρχείο Markdown εδώ για εισαγωγή",
|
||||
"drop_markdown_hint": "Σύρετε και αποθέστε αρχεία ή φακέλους .md εδώ για εισαγωγή",
|
||||
"empty": "δεν υπάρχει σημείωση για τώρα",
|
||||
"expand": "να ανοίξει",
|
||||
"export_failed": "Εξαγωγή στη βάση γνώσης απέτυχε",
|
||||
@ -1715,8 +1719,7 @@
|
||||
"new_note": "Δημιουργία νέας σημείωσης",
|
||||
"no_content_to_copy": "Δεν υπάρχει περιεχόμενο προς αντιγραφή",
|
||||
"no_file_selected": "Επιλέξτε το αρχείο για μεταφόρτωση",
|
||||
"only_markdown": "Υποστηρίζεται μόνο η μορφή Markdown",
|
||||
"only_one_file_allowed": "Μπορείτε να ανεβάσετε μόνο ένα αρχείο",
|
||||
"no_valid_files": "Δεν ανέβηκε έγκυρο αρχείο",
|
||||
"open_folder": "Άνοιγμα εξωτερικού φακέλου",
|
||||
"open_outside": "Από το εξωτερικό",
|
||||
"rename": "μετονομασία",
|
||||
@ -1742,8 +1745,15 @@
|
||||
"compress_content": "μείωση πλάτους στήλης",
|
||||
"compress_content_description": "Ενεργοποιώντας το, θα περιορίζεται ο αριθμός των χαρακτήρων ανά γραμμή, μειώνοντας την οθόνη που εμφανίζεται",
|
||||
"default_font": "προεπιλεγμένη γραμματοσειρά",
|
||||
"font_size": "μέγεθος γραμματοσειράς",
|
||||
"font_size_description": "Ρυθμίστε το μέγεθος της γραμματοσειράς για καλύτερη εμπειρία ανάγνωσης (10-30px)",
|
||||
"font_size_large": "Μεγάλος",
|
||||
"font_size_medium": "中",
|
||||
"font_size_small": "μικρό",
|
||||
"font_title": "ρυθμίσεις γραμματοσειράς",
|
||||
"serif_font": "σειρά γραμματοσειρών",
|
||||
"show_table_of_contents": "Εμφάνιση περιεχομένων καταλόγου",
|
||||
"show_table_of_contents_description": "Εμφάνιση πλευρικής στήλης περιεχομένων για εύκολη πλοήγηση στο έγγραφο",
|
||||
"title": "ρυθμίσεις εμφάνισης"
|
||||
},
|
||||
"editor": {
|
||||
@ -1771,7 +1781,7 @@
|
||||
"sort_updated_asc": "χρόνος ενημέρωσης (από παλιά στα νέα)",
|
||||
"sort_updated_desc": "χρόνος ενημέρωσης (από νεώτερο σε παλαιότερο)",
|
||||
"sort_z2a": "όνομα αρχείου (Z-A)",
|
||||
"star": "Αποθήκευση",
|
||||
"star": "Αγαπημένες σημειώσεις",
|
||||
"starred_notes": "Σημειώσεις συλλογής",
|
||||
"title": "σημειώσεις",
|
||||
"unsaved_changes": "Έχετε μη αποθηκευμένο περιεχόμενο, είστε βέβαιοι ότι θέλετε να φύγετε;",
|
||||
@ -2235,7 +2245,7 @@
|
||||
"changeType": "Αλλαγή τύπου",
|
||||
"deleteProperty": "διαγραφή χαρακτηριστικού",
|
||||
"editValue": "Επεξεργασία τιμής",
|
||||
"empty": "<translate_input>\n空\n</translate_input>",
|
||||
"empty": "Κενό",
|
||||
"moreActions": "Περισσότερες ενέργειες",
|
||||
"propertyName": "όνομα χαρακτηριστικού"
|
||||
},
|
||||
@ -2640,6 +2650,10 @@
|
||||
"url": "URL υπηρεσίας περικοπής Joplin",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
},
|
||||
"limit": {
|
||||
"appDataDiskQuota": "Προειδοποίηση χώρου δίσκου",
|
||||
"appDataDiskQuotaDescription": "Ο κατάλογος δεδομένων της εφαρμογής είναι σχεδόν γεμάτος, παρακαλώ απομακρύνετε τον χώρο δίσκου, αλλιώς θα χαθούν τα δεδομένα"
|
||||
},
|
||||
"local": {
|
||||
"autoSync": {
|
||||
"label": "Αυτόματο αντίγραφο ασφαλείας",
|
||||
|
||||
@ -538,6 +538,10 @@
|
||||
"tip": "En la barra de herramientas de bloques de código ejecutables se mostrará un botón de ejecución. ¡Tenga cuidado en no ejecutar código peligroso!",
|
||||
"title": "Ejecución de Código"
|
||||
},
|
||||
"code_fancy_block": {
|
||||
"label": "Bloque de código con estilo",
|
||||
"tip": "Utiliza un estilo de bloque de código más atractivo, como una tarjeta HTML"
|
||||
},
|
||||
"code_image_tools": {
|
||||
"label": "Habilitar herramienta de vista previa",
|
||||
"tip": "Habilitar herramientas de vista previa para imágenes renderizadas de bloques de código como mermaid"
|
||||
@ -1704,7 +1708,7 @@
|
||||
"delete_confirm": "¿Estás seguro de que deseas eliminar este {{type}}?",
|
||||
"delete_folder_confirm": "¿Está seguro de que desea eliminar la carpeta \"{{name}}\" y todo su contenido?",
|
||||
"delete_note_confirm": "¿Está seguro de que desea eliminar la nota \"{{name}}\"?",
|
||||
"drop_markdown_hint": "Arrastra archivos Markdown aquí para importar",
|
||||
"drop_markdown_hint": "Arrastre y suelte archivos o carpetas de .md aquí para importar",
|
||||
"empty": "Sin notas por el momento",
|
||||
"expand": "expandir",
|
||||
"export_failed": "Exportación a la base de conocimientos fallida",
|
||||
@ -1715,8 +1719,7 @@
|
||||
"new_note": "Crear nota nueva",
|
||||
"no_content_to_copy": "No hay contenido para copiar",
|
||||
"no_file_selected": "Por favor, seleccione el archivo a subir",
|
||||
"only_markdown": "Solo se admite el formato Markdown",
|
||||
"only_one_file_allowed": "solo se puede subir un archivo",
|
||||
"no_valid_files": "No se ha cargado un archivo válido",
|
||||
"open_folder": "abrir carpeta externa",
|
||||
"open_outside": "Abrir desde el exterior",
|
||||
"rename": "renombrar",
|
||||
@ -1742,8 +1745,15 @@
|
||||
"compress_content": "reducir el ancho de la columna",
|
||||
"compress_content_description": "Al activarlo, se limitará el número de caracteres por línea, reduciendo el contenido mostrado en pantalla.",
|
||||
"default_font": "fuente predeterminada",
|
||||
"font_size": "Tamaño de fuente",
|
||||
"font_size_description": "Ajusta el tamaño de la fuente para una mejor experiencia de lectura (10-30px)",
|
||||
"font_size_large": "Grande",
|
||||
"font_size_medium": "中",
|
||||
"font_size_small": "pequeño",
|
||||
"font_title": "Configuración de fuente",
|
||||
"serif_font": "fuente serif",
|
||||
"show_table_of_contents": "Mostrar esquema del directorio",
|
||||
"show_table_of_contents_description": "Mostrar la barra lateral del índice para facilitar la navegación dentro del documento",
|
||||
"title": "configuración de visualización"
|
||||
},
|
||||
"editor": {
|
||||
@ -1771,7 +1781,7 @@
|
||||
"sort_updated_asc": "Fecha de actualización (de más antigua a más reciente)",
|
||||
"sort_updated_desc": "Fecha de actualización (de más nuevo a más antiguo)",
|
||||
"sort_z2a": "Nombre de archivo (Z-A)",
|
||||
"star": "Colección",
|
||||
"star": "Notas guardadas",
|
||||
"starred_notes": "notas guardadas",
|
||||
"title": "notas",
|
||||
"unsaved_changes": "Tienes contenido no guardado, ¿estás seguro de que quieres salir?",
|
||||
@ -2640,6 +2650,10 @@
|
||||
"url": "URL a la que escucha el servicio de recorte de Joplin",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
},
|
||||
"limit": {
|
||||
"appDataDiskQuota": "Advertencia de espacio en disco",
|
||||
"appDataDiskQuotaDescription": "El espacio de almacenamiento de datos está casi lleno, por favor, limpie el espacio en disco, de lo contrario, se perderán los datos"
|
||||
},
|
||||
"local": {
|
||||
"autoSync": {
|
||||
"label": "Copia de seguridad automática",
|
||||
|
||||
@ -538,6 +538,10 @@
|
||||
"tip": "Une bouton d'exécution s'affichera dans la barre d'outils des blocs de code exécutables. Attention à ne pas exécuter de code dangereux !",
|
||||
"title": "Exécution de code"
|
||||
},
|
||||
"code_fancy_block": {
|
||||
"label": "bloc de code fantaisie",
|
||||
"tip": "Utiliser un style de bloc de code plus esthétique, comme une carte HTML"
|
||||
},
|
||||
"code_image_tools": {
|
||||
"label": "Activer l'outil d'aperçu",
|
||||
"tip": "Activer les outils de prévisualisation pour les images rendues des blocs de code tels que mermaid"
|
||||
@ -1704,7 +1708,7 @@
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce {{type}} ?",
|
||||
"delete_folder_confirm": "Êtes-vous sûr de vouloir supprimer le dossier \"{{name}}\" et tout son contenu ?",
|
||||
"delete_note_confirm": "Êtes-vous sûr de vouloir supprimer la note \"{{name}}\" ?",
|
||||
"drop_markdown_hint": "Glissez-déposez le fichier Markdown ici pour l'importer",
|
||||
"drop_markdown_hint": "Déposez ici des fichiers ou dossiers .md pour les importer",
|
||||
"empty": "Aucune note pour le moment",
|
||||
"expand": "développer",
|
||||
"export_failed": "Échec de l'exportation vers la base de connaissances",
|
||||
@ -1715,8 +1719,7 @@
|
||||
"new_note": "Nouvelle note",
|
||||
"no_content_to_copy": "Aucun contenu à copier",
|
||||
"no_file_selected": "Veuillez sélectionner le fichier à télécharger",
|
||||
"only_markdown": "uniquement le format Markdown est pris en charge",
|
||||
"only_one_file_allowed": "On ne peut télécharger qu'un seul fichier",
|
||||
"no_valid_files": "Aucun fichier valide n’a été téléversé",
|
||||
"open_folder": "ouvrir le dossier externe",
|
||||
"open_outside": "Ouvrir depuis l'extérieur",
|
||||
"rename": "renommer",
|
||||
@ -1742,8 +1745,15 @@
|
||||
"compress_content": "réduire la largeur des colonnes",
|
||||
"compress_content_description": "L'activation limitera le nombre de caractères par ligne, réduisant ainsi le contenu affiché à l'écran.",
|
||||
"default_font": "police par défaut",
|
||||
"font_size": "Taille de police",
|
||||
"font_size_description": "Ajuster la taille de la police pour une meilleure expérience de lecture (10-30px)",
|
||||
"font_size_large": "Grand",
|
||||
"font_size_medium": "中",
|
||||
"font_size_small": "petit",
|
||||
"font_title": "paramétrage des polices",
|
||||
"serif_font": "police à empattements",
|
||||
"show_table_of_contents": "Afficher le plan du sommaire",
|
||||
"show_table_of_contents_description": "Afficher la barre latérale de la table des matières pour faciliter la navigation dans le document",
|
||||
"title": "Paramètres d'affichage"
|
||||
},
|
||||
"editor": {
|
||||
@ -1771,7 +1781,7 @@
|
||||
"sort_updated_asc": "Heure de mise à jour (du plus ancien au plus récent)",
|
||||
"sort_updated_desc": "Date de mise à jour (du plus récent au plus ancien)",
|
||||
"sort_z2a": "Nom de fichier (Z-A)",
|
||||
"star": "Favori",
|
||||
"star": "Notes enregistrées",
|
||||
"starred_notes": "notes de collection",
|
||||
"title": "notes",
|
||||
"unsaved_changes": "Vous avez des modifications non enregistrées, êtes-vous sûr de vouloir quitter ?",
|
||||
@ -2640,6 +2650,10 @@
|
||||
"url": "URL surveillée par le service de découpage de Joplin",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
},
|
||||
"limit": {
|
||||
"appDataDiskQuota": "Avertissement d'espace sur le disque",
|
||||
"appDataDiskQuotaDescription": "L'espace de stockage des données est presque plein, veuillez nettoyer l'espace sur le disque, sinon les données seront perdues"
|
||||
},
|
||||
"local": {
|
||||
"autoSync": {
|
||||
"label": "Sauvegarde automatique",
|
||||
|
||||
@ -538,6 +538,10 @@
|
||||
"tip": "実行可能なコードブロックのツールバーには実行ボタンが表示されます。危険なコードを実行しないでください!",
|
||||
"title": "コード実行"
|
||||
},
|
||||
"code_fancy_block": {
|
||||
"label": "<translate_input>\n装飾的なコードブロック\n</translate_input>",
|
||||
"tip": "より見栄えの良いコードブロックスタイルを使用する、例えばHTMLカード"
|
||||
},
|
||||
"code_image_tools": {
|
||||
"label": "プレビューツールを有効にする",
|
||||
"tip": "mermaid などのコードブロックから生成された画像に対してプレビューツールを有効にする"
|
||||
@ -1704,7 +1708,7 @@
|
||||
"delete_confirm": "この{{type}}を本当に削除しますか?",
|
||||
"delete_folder_confirm": "「{{name}}」フォルダーとそのすべての内容を削除してもよろしいですか?",
|
||||
"delete_note_confirm": "メモ \"{{name}}\" を削除してもよろしいですか?",
|
||||
"drop_markdown_hint": "マークダウンファイルをドラッグアンドドロップしてここにインポートします",
|
||||
"drop_markdown_hint": ".md ファイルまたはディレクトリをここにドラッグ&ドロップしてインポートしてください",
|
||||
"empty": "暫無ノート",
|
||||
"expand": "展開",
|
||||
"export_failed": "知識ベースへのエクスポートに失敗しました",
|
||||
@ -1715,8 +1719,7 @@
|
||||
"new_note": "新規ノート作成",
|
||||
"no_content_to_copy": "コピーするコンテンツはありません",
|
||||
"no_file_selected": "アップロードするファイルを選択してください",
|
||||
"only_markdown": "Markdown ファイルのみをアップロードできます",
|
||||
"only_one_file_allowed": "アップロードできるファイルは1つだけです",
|
||||
"no_valid_files": "有効なファイルがアップロードされていません",
|
||||
"open_folder": "外部フォルダーを開きます",
|
||||
"open_outside": "外部から開く",
|
||||
"rename": "名前の変更",
|
||||
@ -1742,8 +1745,15 @@
|
||||
"compress_content": "バーの幅を減らします",
|
||||
"compress_content_description": "有効にすると、1行あたりの単語数が制限され、画面に表示されるコンテンツが減少します。",
|
||||
"default_font": "デフォルトフォント",
|
||||
"font_size": "フォントサイズ",
|
||||
"font_size_description": "フォントサイズを調整して読書体験を向上させる(10-30px)",
|
||||
"font_size_large": "大",
|
||||
"font_size_medium": "中",
|
||||
"font_size_small": "小",
|
||||
"font_title": "フォント設定",
|
||||
"serif_font": "セリフフォント",
|
||||
"show_table_of_contents": "目次アウトラインを表示",
|
||||
"show_table_of_contents_description": "目次アウトラインサイドバーを表示し、文書内のナビゲーションを容易にする",
|
||||
"title": "見せる"
|
||||
},
|
||||
"editor": {
|
||||
@ -1771,7 +1781,7 @@
|
||||
"sort_updated_asc": "更新日時(古い順)",
|
||||
"sort_updated_desc": "更新日時(新しい順)",
|
||||
"sort_z2a": "ファイル名(Z-A)",
|
||||
"star": "お気に入りに追加する",
|
||||
"star": "お気に入りのノート",
|
||||
"starred_notes": "収集したノート",
|
||||
"title": "ノート",
|
||||
"unsaved_changes": "保存されていないコンテンツがあります。本当に離れますか?",
|
||||
@ -2640,6 +2650,10 @@
|
||||
"url": "Joplin 剪輯服務 URL",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
},
|
||||
"limit": {
|
||||
"appDataDiskQuota": "ディスク容量警告",
|
||||
"appDataDiskQuotaDescription": "データディレクトリの容量がほぼ満杯になっており、新しいデータの保存ができなくなる可能性があります。まずデータをバックアップしてから、ディスク容量を整理してください。"
|
||||
},
|
||||
"local": {
|
||||
"autoSync": {
|
||||
"label": "自動バックアップ",
|
||||
|
||||
@ -538,6 +538,10 @@
|
||||
"tip": "A barra de ferramentas de blocos de código executáveis exibirá um botão de execução; atenção para não executar códigos perigosos!",
|
||||
"title": "Execução de Código"
|
||||
},
|
||||
"code_fancy_block": {
|
||||
"label": "Bloco de código estilizado",
|
||||
"tip": "Use um estilo de bloco de código mais agradável, como cartões HTML"
|
||||
},
|
||||
"code_image_tools": {
|
||||
"label": "Habilitar ferramenta de visualização",
|
||||
"tip": "Ativar ferramentas de visualização para imagens renderizadas de blocos de código como mermaid"
|
||||
@ -1704,7 +1708,7 @@
|
||||
"delete_confirm": "Tem a certeza de que deseja eliminar este {{type}}?",
|
||||
"delete_folder_confirm": "Tem a certeza de que deseja eliminar a pasta \"{{name}}\" e todos os seus conteúdos?",
|
||||
"delete_note_confirm": "Tem a certeza de que deseja eliminar a nota \"{{name}}\"?",
|
||||
"drop_markdown_hint": "Arraste o arquivo Markdown para aqui e importe",
|
||||
"drop_markdown_hint": "Arraste e solte arquivos ou pastas .md aqui para importar",
|
||||
"empty": "Ainda não existem notas",
|
||||
"expand": "expandir",
|
||||
"export_failed": "Falha ao exportar para a base de conhecimento",
|
||||
@ -1715,8 +1719,7 @@
|
||||
"new_note": "Nova nota",
|
||||
"no_content_to_copy": "Não há conteúdo para copiar",
|
||||
"no_file_selected": "Selecione o arquivo a ser enviado",
|
||||
"only_markdown": "Apenas o formato Markdown é suportado",
|
||||
"only_one_file_allowed": "só é possível enviar um arquivo",
|
||||
"no_valid_files": "Nenhum arquivo válido foi carregado",
|
||||
"open_folder": "Abrir pasta externa",
|
||||
"open_outside": "Abrir externamente",
|
||||
"rename": "renomear",
|
||||
@ -1742,8 +1745,15 @@
|
||||
"compress_content": "reduzir a largura da coluna",
|
||||
"compress_content_description": "Ativando isso limitará o número de caracteres por linha, reduzindo o conteúdo exibido na tela.",
|
||||
"default_font": "fonte padrão",
|
||||
"font_size": "tamanho da fonte",
|
||||
"font_size_description": "Ajuste o tamanho da fonte para uma melhor experiência de leitura (10-30px)",
|
||||
"font_size_large": "Grande",
|
||||
"font_size_medium": "中",
|
||||
"font_size_small": "pequeno",
|
||||
"font_title": "configuração de fonte",
|
||||
"serif_font": "fonte com serifa",
|
||||
"show_table_of_contents": "Mostrar esboço do diretório",
|
||||
"show_table_of_contents_description": "Mostrar barra lateral do índice, facilitando a navegação dentro do documento",
|
||||
"title": "configurações de exibição"
|
||||
},
|
||||
"editor": {
|
||||
@ -1771,7 +1781,7 @@
|
||||
"sort_updated_asc": "Tempo de atualização (do mais antigo para o mais recente)",
|
||||
"sort_updated_desc": "atualização de tempo (do mais novo para o mais antigo)",
|
||||
"sort_z2a": "Nome do arquivo (Z-A)",
|
||||
"star": "coleções",
|
||||
"star": "Notas favoritas",
|
||||
"starred_notes": "notas salvas",
|
||||
"title": "nota",
|
||||
"unsaved_changes": "Você tem conteúdo não salvo, tem certeza que deseja sair?",
|
||||
@ -2640,6 +2650,10 @@
|
||||
"url": "URL para o qual o serviço de recorte do Joplin está escutando",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
},
|
||||
"limit": {
|
||||
"appDataDiskQuota": "Aviso de espaço em disco",
|
||||
"appDataDiskQuotaDescription": "O espaço de armazenamento de dados está quase cheio, por favor, limpe o espaço em disco, caso contrário, os dados serão perdidos"
|
||||
},
|
||||
"local": {
|
||||
"autoSync": {
|
||||
"label": "Backup automático",
|
||||
|
||||
@ -538,6 +538,10 @@
|
||||
"tip": "Выполнение кода в блоке кода возможно, но не рекомендуется выполнять опасный код!",
|
||||
"title": "Выполнение кода"
|
||||
},
|
||||
"code_fancy_block": {
|
||||
"label": "Форматированные блоки кода",
|
||||
"tip": "Используйте более эстетичный стиль блоков кода, например, HTML-карточки"
|
||||
},
|
||||
"code_image_tools": {
|
||||
"label": "Включить инструменты предпросмотра",
|
||||
"tip": "Включить инструменты предпросмотра для изображений, сгенерированных из блоков кода (например mermaid)"
|
||||
@ -1704,7 +1708,7 @@
|
||||
"delete_confirm": "Вы уверены, что хотите удалить этот объект {{type}}?",
|
||||
"delete_folder_confirm": "Вы уверены, что хотите удалить папку \"{{name}}\" со всем ее содержимым?",
|
||||
"delete_note_confirm": "Вы действительно хотите удалить заметку \"{{name}}\"?",
|
||||
"drop_markdown_hint": "Перетаскивать файл разметки, чтобы импортировать его здесь",
|
||||
"drop_markdown_hint": "Перетащите сюда файлы или папки .md для импорта",
|
||||
"empty": "заметок пока нет",
|
||||
"expand": "развернуть",
|
||||
"export_failed": "Экспорт в базу знаний не выполнен",
|
||||
@ -1715,8 +1719,7 @@
|
||||
"new_note": "Создать заметку",
|
||||
"no_content_to_copy": "Нет контента для копирования",
|
||||
"no_file_selected": "Пожалуйста, выберите файл для загрузки",
|
||||
"only_markdown": "Только Markdown",
|
||||
"only_one_file_allowed": "Можно загрузить только один файл",
|
||||
"no_valid_files": "Не загружен действительный файл",
|
||||
"open_folder": "Откройте внешнюю папку",
|
||||
"open_outside": "открыть снаружи",
|
||||
"rename": "переименовать",
|
||||
@ -1742,8 +1745,15 @@
|
||||
"compress_content": "Уменьшить ширину стержня",
|
||||
"compress_content_description": "При включении он ограничит количество слов на строку, уменьшая содержимое, отображаемое на экране.",
|
||||
"default_font": "По умолчанию шрифт",
|
||||
"font_size": "Размер шрифта",
|
||||
"font_size_description": "Отрегулируйте размер шрифта для лучшего чтения (10–30 пикселей)",
|
||||
"font_size_large": "Большой",
|
||||
"font_size_medium": "中",
|
||||
"font_size_small": "<translate_input>\nмаленький\n</translate_input>",
|
||||
"font_title": "Настройки шрифта",
|
||||
"serif_font": "Serif Font",
|
||||
"show_table_of_contents": "Показать оглавление",
|
||||
"show_table_of_contents_description": "显示目录大纲侧边栏,方便文档内导航",
|
||||
"title": "показывать"
|
||||
},
|
||||
"editor": {
|
||||
@ -1771,7 +1781,7 @@
|
||||
"sort_updated_asc": "Время обновления (от старого к новому)",
|
||||
"sort_updated_desc": "Время обновления (от нового к старому)",
|
||||
"sort_z2a": "Имя файла (Я-А)",
|
||||
"star": "Сохранить",
|
||||
"star": "Избранные заметки",
|
||||
"starred_notes": "Сохраненные заметки",
|
||||
"title": "заметки",
|
||||
"unsaved_changes": "Вы не сохранили содержимое. Вы уверены, что хотите уйти?",
|
||||
@ -2640,6 +2650,10 @@
|
||||
"url": "URL Joplin",
|
||||
"url_placeholder": "http://127.0.0.1:41184/"
|
||||
},
|
||||
"limit": {
|
||||
"appDataDiskQuota": "Предупреждение о пространстве на диске",
|
||||
"appDataDiskQuotaDescription": "Каталог данных почти заполнен, что может привести к невозможности сохранения новых данных. Сначала создайте резервную копию данных, затем освободите дисковое пространство."
|
||||
},
|
||||
"local": {
|
||||
"autoSync": {
|
||||
"label": "Автоматическое резервное копирование",
|
||||
|
||||
@ -43,6 +43,7 @@ const Chat: FC<Props> = (props) => {
|
||||
const { showTopics } = useShowTopics()
|
||||
const { isMultiSelectMode } = useChatContext(props.activeTopic)
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
const chatMaxWidth = useChatMaxWidth()
|
||||
|
||||
const mainRef = React.useRef<HTMLDivElement>(null)
|
||||
const contentSearchRef = React.useRef<ContentSearchRef>(null)
|
||||
@ -153,7 +154,7 @@ const Chat: FC<Props> = (props) => {
|
||||
vertical
|
||||
flex={1}
|
||||
justify="space-between"
|
||||
style={{ maxWidth: '100%', height: mainHeight }}>
|
||||
style={{ maxWidth: chatMaxWidth, height: mainHeight }}>
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
@ -215,7 +216,7 @@ const Container = styled.div`
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
flex: 1;
|
||||
[navbar-position='top'] & {
|
||||
height: calc(100vh - var(--navbar-height) -6px);
|
||||
height: calc(100vh - var(--navbar-height) - 6px);
|
||||
background-color: var(--color-background);
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
|
||||
@ -61,6 +61,8 @@ import styled from 'styled-components'
|
||||
import NarrowLayout from '../Messages/NarrowLayout'
|
||||
import AttachmentPreview from './AttachmentPreview'
|
||||
import InputbarTools, { InputbarToolsRef } from './InputbarTools'
|
||||
import KnowledgeBaseInput from './KnowledgeBaseInput'
|
||||
import MentionModelsInput from './MentionModelsInput'
|
||||
import SendMessageButton from './SendMessageButton'
|
||||
import TokenCount from './TokenCount'
|
||||
|
||||
@ -765,6 +767,19 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
setSelectedKnowledgeBases(bases ?? [])
|
||||
}
|
||||
|
||||
const handleRemoveModel = (model: Model) => {
|
||||
setMentionedModels(mentionedModels.filter((m) => m.id !== model.id))
|
||||
}
|
||||
|
||||
const handleRemoveKnowledgeBase = (knowledgeBase: KnowledgeBase) => {
|
||||
const newKnowledgeBases = assistant.knowledge_bases?.filter((kb) => kb.id !== knowledgeBase.id)
|
||||
updateAssistant({
|
||||
...assistant,
|
||||
knowledge_bases: newKnowledgeBases
|
||||
})
|
||||
setSelectedKnowledgeBases(newKnowledgeBases ?? [])
|
||||
}
|
||||
|
||||
const onEnableGenerateImage = () => {
|
||||
updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage })
|
||||
}
|
||||
@ -851,6 +866,15 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
className={classNames('inputbar-container', inputFocus && 'focus', isFileDragging && 'file-dragging')}
|
||||
ref={containerRef}>
|
||||
{files.length > 0 && <AttachmentPreview files={files} setFiles={setFiles} />}
|
||||
{selectedKnowledgeBases.length > 0 && (
|
||||
<KnowledgeBaseInput
|
||||
selectedKnowledgeBases={selectedKnowledgeBases}
|
||||
onRemoveKnowledgeBase={handleRemoveKnowledgeBase}
|
||||
/>
|
||||
)}
|
||||
{mentionedModels.length > 0 && (
|
||||
<MentionModelsInput selectedModels={mentionedModels} onRemoveModel={handleRemoveModel} />
|
||||
)}
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={onChange}
|
||||
|
||||
@ -64,14 +64,14 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
description: t('settings.input.clear.knowledge_base'),
|
||||
icon: <CircleX />,
|
||||
isSelected: false,
|
||||
action: () => {
|
||||
action: ({ context: ctx }) => {
|
||||
onSelect([])
|
||||
quickPanel.close()
|
||||
ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}, [knowledgeState.bases, t, selectedBases, handleBaseSelect, navigate, onSelect, quickPanel])
|
||||
}, [knowledgeState.bases, t, selectedBases, handleBaseSelect, navigate, onSelect])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanel.open({
|
||||
@ -93,6 +93,14 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
}
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
// 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态
|
||||
useEffect(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '#') {
|
||||
// 直接使用重新计算的 baseItems,因为它已经包含了最新的 isSelected 状态
|
||||
quickPanel.updateList(baseItems)
|
||||
}
|
||||
}, [selectedBases, quickPanel, baseItems])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { FileSearchOutlined } from '@ant-design/icons'
|
||||
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
|
||||
import CustomTag from '@renderer/components/Tags/CustomTag'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { FC } from 'react'
|
||||
@ -10,16 +11,18 @@ const KnowledgeBaseInput: FC<{
|
||||
}> = ({ selectedKnowledgeBases, onRemoveKnowledgeBase }) => {
|
||||
return (
|
||||
<Container>
|
||||
{selectedKnowledgeBases.map((knowledgeBase) => (
|
||||
<CustomTag
|
||||
icon={<FileSearchOutlined />}
|
||||
color="#3d9d0f"
|
||||
key={knowledgeBase.id}
|
||||
closable
|
||||
onClose={() => onRemoveKnowledgeBase(knowledgeBase)}>
|
||||
{knowledgeBase.name}
|
||||
</CustomTag>
|
||||
))}
|
||||
<HorizontalScrollContainer dependencies={[selectedKnowledgeBases]} expandable>
|
||||
{selectedKnowledgeBases.map((knowledgeBase) => (
|
||||
<CustomTag
|
||||
icon={<FileSearchOutlined />}
|
||||
color="#3d9d0f"
|
||||
key={knowledgeBase.id}
|
||||
closable
|
||||
onClose={() => onRemoveKnowledgeBase(knowledgeBase)}>
|
||||
{knowledgeBase.name}
|
||||
</CustomTag>
|
||||
))}
|
||||
</HorizontalScrollContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -27,9 +30,6 @@ const KnowledgeBaseInput: FC<{
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
padding: 5px 15px 5px 15px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 4px;
|
||||
`
|
||||
|
||||
export default KnowledgeBaseInput
|
||||
|
||||
@ -202,7 +202,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
icon: <CircleX />,
|
||||
alwaysVisible: true,
|
||||
isSelected: false,
|
||||
action: () => {
|
||||
action: ({ context: ctx }) => {
|
||||
onClearMentionModels()
|
||||
|
||||
// 只有输入触发时才需要删除 @ 与搜索文本(未知搜索词,按光标就近删除)
|
||||
@ -214,7 +214,7 @@ const MentionModelsButton: FC<Props> = ({
|
||||
})
|
||||
}
|
||||
|
||||
quickPanel.close()
|
||||
ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
@ -227,7 +227,6 @@ const MentionModelsButton: FC<Props> = ({
|
||||
mentionedModels,
|
||||
onMentionModel,
|
||||
navigate,
|
||||
quickPanel,
|
||||
onClearMentionModels,
|
||||
setText,
|
||||
removeAtSymbolAndText
|
||||
@ -249,20 +248,20 @@ const MentionModelsButton: FC<Props> = ({
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
},
|
||||
onClose({ action, triggerInfo: closeTriggerInfo, searchText }) {
|
||||
onClose({ action, searchText, context: ctx }) {
|
||||
// ESC关闭时的处理:删除 @ 和搜索文本
|
||||
if (action === 'esc') {
|
||||
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
|
||||
if (
|
||||
hasModelActionRef.current &&
|
||||
closeTriggerInfo?.type === 'input' &&
|
||||
closeTriggerInfo?.position !== undefined
|
||||
ctx.triggerInfo?.type === 'input' &&
|
||||
ctx.triggerInfo?.position !== undefined
|
||||
) {
|
||||
// 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底
|
||||
setText((currentText) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
||||
return removeAtSymbolAndText(currentText, caret, searchText || '', closeTriggerInfo.position!)
|
||||
return removeAtSymbolAndText(currentText, caret, searchText || '', ctx.triggerInfo?.position!)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -294,6 +293,14 @@ const MentionModelsButton: FC<Props> = ({
|
||||
}
|
||||
}, [files, quickPanel])
|
||||
|
||||
// 监听 mentionedModels 变化,动态更新已打开的 QuickPanel 列表状态
|
||||
useEffect(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === '@') {
|
||||
// 直接使用重新计算的 modelItems,因为它已经包含了最新的 isSelected 状态
|
||||
quickPanel.updateList(modelItems)
|
||||
}
|
||||
}, [mentionedModels, quickPanel, modelItems])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
44
src/renderer/src/pages/home/Inputbar/MentionModelsInput.tsx
Normal file
44
src/renderer/src/pages/home/Inputbar/MentionModelsInput.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
|
||||
import CustomTag from '@renderer/components/Tags/CustomTag'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { getFancyProviderName } from '@renderer/utils'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const MentionModelsInput: FC<{
|
||||
selectedModels: Model[]
|
||||
onRemoveModel: (model: Model) => void
|
||||
}> = ({ selectedModels, onRemoveModel }) => {
|
||||
const { providers } = useProviders()
|
||||
|
||||
const getProviderName = (model: Model) => {
|
||||
const provider = providers.find((p) => p.id === model?.provider)
|
||||
return provider ? getFancyProviderName(provider) : ''
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<HorizontalScrollContainer dependencies={[selectedModels]} expandable>
|
||||
{selectedModels.map((model) => (
|
||||
<CustomTag
|
||||
icon={<i className="iconfont icon-at" />}
|
||||
color="#1677ff"
|
||||
key={getModelUniqId(model)}
|
||||
closable
|
||||
onClose={() => onRemoveModel(model)}>
|
||||
{model.name} ({getProviderName(model)})
|
||||
</CustomTag>
|
||||
))}
|
||||
</HorizontalScrollContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
padding: 5px 15px 5px 15px;
|
||||
`
|
||||
|
||||
export default MentionModelsInput
|
||||
@ -266,6 +266,7 @@ const Alert = styled(AntdAlert)`
|
||||
margin: 0.5rem 0 !important;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
& .ant-alert-close-icon {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ const Container = styled.div<{ $isDark: boolean }>`
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
border: 0.5px solid var(--color-border);
|
||||
margin: 15px 24px;
|
||||
margin: 15px 20px;
|
||||
margin-bottom: 0;
|
||||
`
|
||||
|
||||
|
||||
@ -46,10 +46,7 @@ const AppsPage: FC = () => {
|
||||
style={{
|
||||
width: '30%',
|
||||
height: 28,
|
||||
borderRadius: 15,
|
||||
position: 'absolute',
|
||||
left: '50vw',
|
||||
transform: 'translateX(-50%)'
|
||||
borderRadius: 15
|
||||
}}
|
||||
size="small"
|
||||
variant="filled"
|
||||
@ -107,6 +104,7 @@ const Container = styled.div`
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
@ -132,11 +130,13 @@ const MainContainer = styled.div`
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const RightContainer = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
@ -150,6 +150,7 @@ const AppsContainerWrapper = styled(Scrollbar)`
|
||||
justify-content: center;
|
||||
padding: 50px 0;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
[navbar-position='top'] & {
|
||||
padding: 20px 0;
|
||||
}
|
||||
@ -159,6 +160,7 @@ const AppsContainer = styled.div`
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
max-width: 930px;
|
||||
margin: 0 20px;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fill, 90px);
|
||||
gap: 25px;
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { BreadcrumbItem, Breadcrumbs } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import { findNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import { Breadcrumb, BreadcrumbProps, Dropdown, Tooltip } from 'antd'
|
||||
import { findNodeByPath, findNodeInTree, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import { NotesTreeNode } from '@types'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
@ -18,7 +20,9 @@ const logger = loggerService.withContext('HeaderNavbar')
|
||||
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
|
||||
const { activeNode } = useActiveNode(notesTree)
|
||||
const [breadcrumbItems, setBreadcrumbItems] = useState<Required<BreadcrumbProps>['items']>([])
|
||||
const [breadcrumbItems, setBreadcrumbItems] = useState<
|
||||
Array<{ key: string; title: string; treePath: string; isFolder: boolean }>
|
||||
>([])
|
||||
const { settings, updateSettings } = useNotesSettings()
|
||||
const canShowStarButton = activeNode?.type === 'file' && onToggleStar
|
||||
|
||||
@ -47,6 +51,40 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
}
|
||||
}, [getCurrentNoteContent])
|
||||
|
||||
const handleBreadcrumbClick = useCallback(
|
||||
async (item: { treePath: string; isFolder: boolean }) => {
|
||||
if (item.isFolder && notesTree) {
|
||||
try {
|
||||
// 获取从根目录到点击目录的所有路径片段
|
||||
const pathParts = item.treePath.split('/').filter(Boolean)
|
||||
const expandPromises: Promise<NotesTreeNode>[] = []
|
||||
|
||||
// 逐级展开从根到目标路径的所有文件夹
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
const currentPath = '/' + pathParts.slice(0, i + 1).join('/')
|
||||
const folderNode = findNodeByPath(notesTree, currentPath)
|
||||
|
||||
if (folderNode && folderNode.type === 'folder' && !folderNode.expanded) {
|
||||
expandPromises.push(updateNodeInTree(notesTree, folderNode.id, { expanded: true }))
|
||||
}
|
||||
}
|
||||
|
||||
// 并行执行所有展开操作
|
||||
if (expandPromises.length > 0) {
|
||||
await Promise.all(expandPromises)
|
||||
logger.info('Expanded folder path from breadcrumb:', {
|
||||
targetPath: item.treePath,
|
||||
expandedCount: expandPromises.length
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to expand folder path from breadcrumb:', error as Error)
|
||||
}
|
||||
}
|
||||
},
|
||||
[notesTree]
|
||||
)
|
||||
|
||||
const buildMenuItem = (item: any) => {
|
||||
if (item.type === 'divider') {
|
||||
return { type: 'divider' as const, key: item.key }
|
||||
@ -106,9 +144,13 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
|
||||
const pathParts = node.treePath.split('/').filter(Boolean)
|
||||
const items = pathParts.map((part, index) => {
|
||||
const currentPath = '/' + pathParts.slice(0, index + 1).join('/')
|
||||
const isLastItem = index === pathParts.length - 1
|
||||
return {
|
||||
key: `path-${index}`,
|
||||
title: part
|
||||
title: part,
|
||||
treePath: currentPath,
|
||||
isFolder: !isLastItem || node.type === 'folder'
|
||||
}
|
||||
})
|
||||
|
||||
@ -135,8 +177,20 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<NavbarCenter style={{ flex: 1 }}>
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
<NavbarCenter style={{ flex: 1, minWidth: 0 }}>
|
||||
<BreadcrumbsContainer>
|
||||
<Breadcrumbs>
|
||||
{breadcrumbItems.map((item, index) => (
|
||||
<BreadcrumbItem key={item.key} isCurrent={index === breadcrumbItems.length - 1}>
|
||||
<BreadcrumbTitle
|
||||
onClick={() => handleBreadcrumbClick(item)}
|
||||
$clickable={item.isFolder && index < breadcrumbItems.length - 1}>
|
||||
{item.title}
|
||||
</BreadcrumbTitle>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
</BreadcrumbsContainer>
|
||||
</NavbarCenter>
|
||||
<NavbarRight style={{ paddingRight: 0 }}>
|
||||
{canShowStarButton && (
|
||||
@ -225,4 +279,55 @@ export const StarButton = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
export const BreadcrumbsContainer = styled.div`
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
/* 确保 HeroUI Breadcrumbs 组件保持在一行 */
|
||||
& > nav {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& ol {
|
||||
flex-wrap: nowrap !important;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
& li {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 确保分隔符不会与标题重叠 */
|
||||
& li:not(:last-child)::after {
|
||||
flex-shrink: 0;
|
||||
margin: 0 8px;
|
||||
}
|
||||
`
|
||||
|
||||
export const BreadcrumbTitle = styled.span<{ $clickable?: boolean }>`
|
||||
max-width: 150px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
|
||||
${({ $clickable }) =>
|
||||
$clickable &&
|
||||
`
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
export default HeaderNavbar
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
moveNode,
|
||||
renameNode,
|
||||
sortAllLevels,
|
||||
uploadNote
|
||||
uploadFiles
|
||||
} from '@renderer/services/NotesService'
|
||||
import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
@ -525,38 +525,36 @@ const NotesPage: FC = () => {
|
||||
const handleUploadFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
try {
|
||||
const fileToUpload = files[0]
|
||||
|
||||
if (!fileToUpload) {
|
||||
if (!files || files.length === 0) {
|
||||
window.toast.warning(t('notes.no_file_selected'))
|
||||
return
|
||||
}
|
||||
// 暂时这么处理
|
||||
if (files.length > 1) {
|
||||
window.toast.warning(t('notes.only_one_file_allowed'))
|
||||
|
||||
const targetFolderPath = getTargetFolderPath()
|
||||
if (!targetFolderPath) {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
|
||||
if (!fileToUpload.name.toLowerCase().endsWith('.md')) {
|
||||
window.toast.warning(t('notes.only_markdown'))
|
||||
const result = await uploadFiles(files, targetFolderPath)
|
||||
|
||||
// 检查上传结果
|
||||
if (result.fileCount === 0) {
|
||||
window.toast.warning(t('notes.no_valid_files'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!notesPath) {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
await uploadNote(fileToUpload, notesPath)
|
||||
window.toast.success(t('notes.upload_success', { count: 1 }))
|
||||
} catch (error) {
|
||||
logger.error(`Failed to upload note file ${fileToUpload.name}:`, error as Error)
|
||||
window.toast.error(t('notes.upload_failed', { name: fileToUpload.name }))
|
||||
}
|
||||
// 排序并显示成功信息
|
||||
await sortAllLevels(sortType)
|
||||
|
||||
const successMessage = t('notes.upload_success')
|
||||
|
||||
window.toast.success(successMessage)
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle file upload:', error as Error)
|
||||
window.toast.error(t('notes.upload_failed'))
|
||||
}
|
||||
},
|
||||
[notesPath, t]
|
||||
[getTargetFolderPath, sortType, t]
|
||||
)
|
||||
|
||||
// 处理节点移动
|
||||
|
||||
@ -461,19 +461,82 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
)
|
||||
|
||||
const handleDropFiles = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOverSidebar(false)
|
||||
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
// 处理文件夹拖拽:从 dataTransfer.items 获取完整的文件路径信息
|
||||
const items = Array.from(e.dataTransfer.items)
|
||||
const files: File[] = []
|
||||
|
||||
if (files.length > 0) {
|
||||
onUploadFiles(files)
|
||||
const processEntry = async (entry: FileSystemEntry, path: string = '') => {
|
||||
if (entry.isFile) {
|
||||
const fileEntry = entry as FileSystemFileEntry
|
||||
return new Promise<void>((resolve) => {
|
||||
fileEntry.file((file) => {
|
||||
// 手动设置 webkitRelativePath 以保持文件夹结构
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: path + file.name,
|
||||
writable: false
|
||||
})
|
||||
files.push(file)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
} else if (entry.isDirectory) {
|
||||
const dirEntry = entry as FileSystemDirectoryEntry
|
||||
const reader = dirEntry.createReader()
|
||||
return new Promise<void>((resolve) => {
|
||||
reader.readEntries(async (entries) => {
|
||||
const promises = entries.map((subEntry) => processEntry(subEntry, path + entry.name + '/'))
|
||||
await Promise.all(promises)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果支持 DataTransferItem API(文件夹拖拽)
|
||||
if (items.length > 0 && items[0].webkitGetAsEntry()) {
|
||||
const promises = items.map((item) => {
|
||||
const entry = item.webkitGetAsEntry()
|
||||
return entry ? processEntry(entry) : Promise.resolve()
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
if (files.length > 0) {
|
||||
onUploadFiles(files)
|
||||
}
|
||||
} else {
|
||||
const regularFiles = Array.from(e.dataTransfer.files)
|
||||
if (regularFiles.length > 0) {
|
||||
onUploadFiles(regularFiles)
|
||||
}
|
||||
}
|
||||
},
|
||||
[onUploadFiles]
|
||||
)
|
||||
|
||||
const handleClickToSelectFiles = useCallback(() => {
|
||||
const fileInput = document.createElement('input')
|
||||
fileInput.type = 'file'
|
||||
fileInput.multiple = true
|
||||
fileInput.accept = '.md,.markdown'
|
||||
fileInput.webkitdirectory = false
|
||||
|
||||
fileInput.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files && target.files.length > 0) {
|
||||
const selectedFiles = Array.from(target.files)
|
||||
onUploadFiles(selectedFiles)
|
||||
}
|
||||
fileInput.remove()
|
||||
}
|
||||
|
||||
fileInput.click()
|
||||
}, [onUploadFiles])
|
||||
|
||||
return (
|
||||
<SidebarContainer
|
||||
onDragOver={(e) => {
|
||||
@ -512,7 +575,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
<NodeIcon>
|
||||
<FilePlus size={16} />
|
||||
</NodeIcon>
|
||||
<DropHintText>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</DropHintNode>
|
||||
@ -674,12 +737,6 @@ const NodeName = styled.div`
|
||||
const EditInput = styled(Input)`
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
|
||||
.ant-input {
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
border: 0.5px solid var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const DragOverIndicator = styled.div`
|
||||
|
||||
@ -69,18 +69,18 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
||||
<HeaderActions>
|
||||
{!isShowStarred && !isShowSearch && (
|
||||
<>
|
||||
<Tooltip title={t('notes.new_folder')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onCreateFolder}>
|
||||
<FolderPlus size={18} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('notes.new_note')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onCreateNote}>
|
||||
<FilePlus2 size={18} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('notes.new_folder')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onCreateFolder}>
|
||||
<FolderPlus size={18} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: sortMenuWithCheck,
|
||||
|
||||
@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setMCPServers } from '@renderer/store/mcp'
|
||||
import { MCPServer, safeValidateMcpConfig } from '@renderer/types'
|
||||
import { parseJSON } from '@renderer/utils'
|
||||
import { formatZodError } from '@renderer/utils/error'
|
||||
import { formatErrorMessage, formatZodError } from '@renderer/utils/error'
|
||||
import { Modal, Spin, Typography } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -80,11 +80,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const server: MCPServer = {
|
||||
id,
|
||||
isActive: false,
|
||||
...(serverConfig as any)
|
||||
}
|
||||
|
||||
if (!server.name) {
|
||||
server.name = id
|
||||
name: serverConfig.name || id,
|
||||
...serverConfig
|
||||
}
|
||||
|
||||
serversArray.push(server)
|
||||
@ -95,9 +92,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
window.toast.success(t('settings.mcp.jsonSaveSuccess'))
|
||||
setJsonError('')
|
||||
setOpen(false)
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to save JSON config:', error)
|
||||
setJsonError(error.message || t('settings.mcp.jsonSaveError'))
|
||||
} catch (error: unknown) {
|
||||
setJsonError(formatErrorMessage(error) || t('settings.mcp.jsonSaveError'))
|
||||
window.toast.error(t('settings.mcp.jsonSaveError'))
|
||||
} finally {
|
||||
setJsonSaving(false)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { McpError } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { DeleteIcon } from '@renderer/components/Icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMCPServer, useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
@ -424,7 +425,7 @@ const McpSettings: React.FC = () => {
|
||||
} catch (error: any) {
|
||||
window.modal.error({
|
||||
title: t('settings.mcp.startError'),
|
||||
content: formatMcpError(error),
|
||||
content: formatMcpError(error as McpError),
|
||||
centered: true
|
||||
})
|
||||
updateMCPServer({ ...server, isActive: oldActiveState })
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { MCPServer } from '@renderer/types'
|
||||
import { getMcpServerType, type MCPServer } from '@renderer/types'
|
||||
import i18next from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('ModelScopeSyncUtils')
|
||||
@ -104,13 +104,13 @@ export const syncModelScopeServers = async (
|
||||
|
||||
// Check if server already exists
|
||||
const existingServer = existingServers.find((s) => s.id === `@modelscope/${server.id}`)
|
||||
|
||||
const url = server.operational_urls[0].url
|
||||
const mcpServer: MCPServer = {
|
||||
id: `@modelscope/${server.id}`,
|
||||
name: server.chinese_name || server.name || `ModelScope Server ${nanoid()}`,
|
||||
description: server.description || '',
|
||||
type: 'sse',
|
||||
baseUrl: server.operational_urls[0].url,
|
||||
type: getMcpServerType(url),
|
||||
baseUrl: url,
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
|
||||
@ -328,18 +328,16 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
)}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
{apiKeyWebsite && (
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<HStack>
|
||||
{!isDmxapi && (
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<HStack>
|
||||
{apiKeyWebsite && !isDmxapi && (
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
{!isDmxapi && !isAnthropicOAuth() && (
|
||||
<>
|
||||
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
|
||||
@ -11,7 +11,7 @@ import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle
|
||||
|
||||
const BasicSettings: FC = () => {
|
||||
const { theme } = useTheme()
|
||||
const { searchWithTime, maxResults } = useWebSearchSettings()
|
||||
const { searchWithTime, maxResults, compressionConfig } = useWebSearchSettings()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
@ -28,7 +28,7 @@ const BasicSettings: FC = () => {
|
||||
<SettingRow style={{ height: 40 }}>
|
||||
<SettingRowTitle style={{ minWidth: 120 }}>
|
||||
{t('settings.tool.websearch.search_max_result.label')}
|
||||
{maxResults > 20 && (
|
||||
{maxResults > 20 && compressionConfig?.method === 'none' && (
|
||||
<Tooltip title={t('settings.tool.websearch.search_max_result.tooltip')} placement="top">
|
||||
<Info size={16} color="var(--color-icon)" style={{ marginLeft: 5, cursor: 'pointer' }} />
|
||||
</Tooltip>
|
||||
|
||||
@ -5,6 +5,7 @@ import ExaLogo from '@renderer/assets/images/search/exa.png'
|
||||
import SearxngLogo from '@renderer/assets/images/search/searxng.svg'
|
||||
import TavilyLogo from '@renderer/assets/images/search/tavily.png'
|
||||
import ZhipuLogo from '@renderer/assets/images/search/zhipu.png'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ApiKeyListPopup from '@renderer/components/Popups/ApiKeyListPopup/popup'
|
||||
import { WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
@ -205,9 +206,13 @@ const WebSearchProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: 5 }}>
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.api_key.tip')}
|
||||
</SettingHelpLink>
|
||||
<HStack>
|
||||
{apiKeyWebsite && (
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
)}
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
</>
|
||||
|
||||
@ -96,41 +96,48 @@ export async function createNote(name: string, content: string = '', folderPath:
|
||||
return note
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
uploadedNodes: NotesTreeNode[]
|
||||
totalFiles: number
|
||||
skippedFiles: number
|
||||
fileCount: number
|
||||
folderCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传笔记
|
||||
* 上传文件或文件夹,支持单个或批量上传,保持文件夹结构
|
||||
*/
|
||||
export async function uploadNote(file: File, folderPath: string): Promise<NotesTreeNode> {
|
||||
export async function uploadFiles(files: File[], targetFolderPath: string): Promise<UploadResult> {
|
||||
const tree = await getNotesTree()
|
||||
const fileName = file.name.toLowerCase()
|
||||
if (!fileName.endsWith(MARKDOWN_EXT)) {
|
||||
throw new Error('Only markdown files are allowed')
|
||||
const uploadedNodes: NotesTreeNode[] = []
|
||||
let skippedFiles = 0
|
||||
|
||||
const markdownFiles = filterMarkdownFiles(files)
|
||||
skippedFiles = files.length - markdownFiles.length
|
||||
|
||||
if (markdownFiles.length === 0) {
|
||||
return createEmptyUploadResult(files.length, skippedFiles)
|
||||
}
|
||||
|
||||
const noteId = uuidv4()
|
||||
const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '')
|
||||
// 处理重复的根文件夹名称
|
||||
const processedFiles = await processDuplicateRootFolders(markdownFiles, targetFolderPath)
|
||||
|
||||
const { safeName, exists } = await window.api.file.checkFileName(folderPath, nameWithoutExt, true)
|
||||
if (exists) {
|
||||
logger.warn(`Note already exists: ${safeName}`)
|
||||
const { filesByPath, foldersToCreate } = groupFilesByPath(processedFiles, targetFolderPath)
|
||||
|
||||
const createdFolders = await createFoldersSequentially(foldersToCreate, targetFolderPath, tree, uploadedNodes)
|
||||
|
||||
await uploadAllFiles(filesByPath, targetFolderPath, tree, createdFolders, uploadedNodes)
|
||||
|
||||
const fileCount = uploadedNodes.filter((node) => node.type === 'file').length
|
||||
const folderCount = uploadedNodes.filter((node) => node.type === 'folder').length
|
||||
|
||||
return {
|
||||
uploadedNodes,
|
||||
totalFiles: files.length,
|
||||
skippedFiles,
|
||||
fileCount,
|
||||
folderCount
|
||||
}
|
||||
|
||||
const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}`
|
||||
|
||||
const note: NotesTreeNode = {
|
||||
id: noteId,
|
||||
name: safeName,
|
||||
treePath: `/${safeName}`,
|
||||
externalPath: notePath,
|
||||
type: 'file',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
const content = await file.text()
|
||||
await window.api.file.write(notePath, content)
|
||||
insertNodeIntoTree(tree, note)
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
/**
|
||||
@ -148,7 +155,7 @@ export async function deleteNode(nodeId: string): Promise<void> {
|
||||
await window.api.file.deleteExternalFile(node.externalPath)
|
||||
}
|
||||
|
||||
removeNodeFromTree(tree, nodeId)
|
||||
await removeNodeFromTree(tree, nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -387,3 +394,351 @@ function findNodeByExternalPath(nodes: NotesTreeNode[], externalPath: string): N
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤出 Markdown 文件
|
||||
*/
|
||||
function filterMarkdownFiles(files: File[]): File[] {
|
||||
return Array.from(files).filter((file) => {
|
||||
if (file.name.toLowerCase().endsWith(MARKDOWN_EXT)) {
|
||||
return true
|
||||
}
|
||||
logger.warn(`Skipping non-markdown file: ${file.name}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空的上传结果
|
||||
*/
|
||||
function createEmptyUploadResult(totalFiles: number, skippedFiles: number): UploadResult {
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles,
|
||||
skippedFiles,
|
||||
fileCount: 0,
|
||||
folderCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理重复的根文件夹名称,为重复的文件夹重写 webkitRelativePath
|
||||
*/
|
||||
async function processDuplicateRootFolders(markdownFiles: File[], targetFolderPath: string): Promise<File[]> {
|
||||
// 按根文件夹名称分组文件
|
||||
const filesByRootFolder = new Map<string, File[]>()
|
||||
const processedFiles: File[] = []
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
const filePath = file.webkitRelativePath || file.name
|
||||
|
||||
if (filePath.includes('/')) {
|
||||
const rootFolderName = filePath.substring(0, filePath.indexOf('/'))
|
||||
if (!filesByRootFolder.has(rootFolderName)) {
|
||||
filesByRootFolder.set(rootFolderName, [])
|
||||
}
|
||||
filesByRootFolder.get(rootFolderName)!.push(file)
|
||||
} else {
|
||||
// 单个文件,直接添加
|
||||
processedFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个根文件夹组生成唯一的文件夹名称
|
||||
for (const [rootFolderName, files] of filesByRootFolder.entries()) {
|
||||
const { safeName } = await window.api.file.checkFileName(targetFolderPath, rootFolderName, false)
|
||||
|
||||
for (const file of files) {
|
||||
// 创建一个新的 File 对象,并修改 webkitRelativePath
|
||||
const originalPath = file.webkitRelativePath || file.name
|
||||
const relativePath = originalPath.substring(originalPath.indexOf('/') + 1)
|
||||
const newPath = `${safeName}/${relativePath}`
|
||||
|
||||
const newFile = new File([file], file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
})
|
||||
|
||||
Object.defineProperty(newFile, 'webkitRelativePath', {
|
||||
value: newPath,
|
||||
writable: false
|
||||
})
|
||||
|
||||
processedFiles.push(newFile)
|
||||
}
|
||||
}
|
||||
|
||||
return processedFiles
|
||||
}
|
||||
|
||||
/**
|
||||
* 按路径分组文件并收集需要创建的文件夹
|
||||
*/
|
||||
function groupFilesByPath(
|
||||
markdownFiles: File[],
|
||||
targetFolderPath: string
|
||||
): { filesByPath: Map<string, File[]>; foldersToCreate: Set<string> } {
|
||||
const filesByPath = new Map<string, File[]>()
|
||||
const foldersToCreate = new Set<string>()
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
const filePath = file.webkitRelativePath || file.name
|
||||
const relativeDirPath = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : ''
|
||||
const fullDirPath = relativeDirPath ? `${targetFolderPath}/${relativeDirPath}` : targetFolderPath
|
||||
|
||||
if (relativeDirPath) {
|
||||
const pathParts = relativeDirPath.split('/')
|
||||
|
||||
let currentPath = targetFolderPath
|
||||
for (const part of pathParts) {
|
||||
currentPath = `${currentPath}/${part}`
|
||||
foldersToCreate.add(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (!filesByPath.has(fullDirPath)) {
|
||||
filesByPath.set(fullDirPath, [])
|
||||
}
|
||||
filesByPath.get(fullDirPath)!.push(file)
|
||||
}
|
||||
|
||||
return { filesByPath, foldersToCreate }
|
||||
}
|
||||
|
||||
/**
|
||||
* 顺序创建文件夹(避免竞争条件)
|
||||
*/
|
||||
async function createFoldersSequentially(
|
||||
foldersToCreate: Set<string>,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
uploadedNodes: NotesTreeNode[]
|
||||
): Promise<Map<string, NotesTreeNode>> {
|
||||
const createdFolders = new Map<string, NotesTreeNode>()
|
||||
const sortedFolders = Array.from(foldersToCreate).sort()
|
||||
const folderCreationLock = new Set<string>()
|
||||
|
||||
for (const folderPath of sortedFolders) {
|
||||
if (folderCreationLock.has(folderPath)) {
|
||||
continue
|
||||
}
|
||||
folderCreationLock.add(folderPath)
|
||||
|
||||
try {
|
||||
const result = await createSingleFolder(folderPath, targetFolderPath, tree, createdFolders)
|
||||
if (result) {
|
||||
createdFolders.set(folderPath, result)
|
||||
if (result.externalPath !== folderPath) {
|
||||
createdFolders.set(result.externalPath, result)
|
||||
}
|
||||
uploadedNodes.push(result)
|
||||
logger.debug(`Created folder: ${folderPath} -> ${result.externalPath}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create folder ${folderPath}:`, error as Error)
|
||||
} finally {
|
||||
folderCreationLock.delete(folderPath)
|
||||
}
|
||||
}
|
||||
|
||||
return createdFolders
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个文件夹
|
||||
*/
|
||||
async function createSingleFolder(
|
||||
folderPath: string,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
createdFolders: Map<string, NotesTreeNode>
|
||||
): Promise<NotesTreeNode | null> {
|
||||
const existingNode = findNodeByExternalPath(tree, folderPath)
|
||||
if (existingNode) {
|
||||
return existingNode
|
||||
}
|
||||
|
||||
const relativePath = folderPath.replace(targetFolderPath + '/', '')
|
||||
const originalFolderName = relativePath.split('/').pop()!
|
||||
const parentFolderPath = folderPath.substring(0, folderPath.lastIndexOf('/'))
|
||||
|
||||
const { safeName: safeFolderName, exists } = await window.api.file.checkFileName(
|
||||
parentFolderPath,
|
||||
originalFolderName,
|
||||
false
|
||||
)
|
||||
|
||||
const actualFolderPath = `${parentFolderPath}/${safeFolderName}`
|
||||
|
||||
if (exists) {
|
||||
logger.warn(`Folder already exists, creating with new name: ${originalFolderName} -> ${safeFolderName}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await window.api.file.mkdir(actualFolderPath)
|
||||
} catch (error) {
|
||||
logger.debug(`Error creating folder: ${actualFolderPath}`, error as Error)
|
||||
}
|
||||
|
||||
let parentNode: NotesTreeNode | null
|
||||
if (parentFolderPath === targetFolderPath) {
|
||||
parentNode =
|
||||
tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath)
|
||||
} else {
|
||||
parentNode = createdFolders.get(parentFolderPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = tree.find((node) => node.externalPath === parentFolderPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = findNodeByExternalPath(tree, parentFolderPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const folderId = uuidv4()
|
||||
const folder: NotesTreeNode = {
|
||||
id: folderId,
|
||||
name: safeFolderName,
|
||||
treePath: parentNode ? `${parentNode.treePath}/${safeFolderName}` : `/${safeFolderName}`,
|
||||
externalPath: actualFolderPath,
|
||||
type: 'folder',
|
||||
children: [],
|
||||
expanded: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
await insertNodeIntoTree(tree, folder, parentNode?.id)
|
||||
return folder
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容(支持大文件处理)
|
||||
*/
|
||||
async function readFileContent(file: File): Promise<string> {
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
logger.warn(
|
||||
`Large file detected (${Math.round(file.size / 1024 / 1024)}MB): ${file.name}. Consider using streaming for better performance.`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return await file.text()
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read file content for ${file.name}:`, error as Error)
|
||||
throw new Error(`Failed to read file content: ${file.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传所有文件
|
||||
*/
|
||||
async function uploadAllFiles(
|
||||
filesByPath: Map<string, File[]>,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
createdFolders: Map<string, NotesTreeNode>,
|
||||
uploadedNodes: NotesTreeNode[]
|
||||
): Promise<void> {
|
||||
const uploadPromises: Promise<NotesTreeNode | null>[] = []
|
||||
|
||||
for (const [dirPath, dirFiles] of filesByPath.entries()) {
|
||||
for (const file of dirFiles) {
|
||||
const uploadPromise = uploadSingleFile(file, dirPath, targetFolderPath, tree, createdFolders)
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
logger.debug(`Uploaded file: ${result.externalPath}`)
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`Failed to upload file ${file.name}:`, error as Error)
|
||||
return null
|
||||
})
|
||||
|
||||
uploadPromises.push(uploadPromise)
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(uploadPromises)
|
||||
|
||||
results.forEach((result) => {
|
||||
if (result) {
|
||||
uploadedNodes.push(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单个文件,需要根据实际创建的文件夹路径来找到正确的父节点
|
||||
*/
|
||||
async function uploadSingleFile(
|
||||
file: File,
|
||||
originalDirPath: string,
|
||||
targetFolderPath: string,
|
||||
tree: NotesTreeNode[],
|
||||
createdFolders: Map<string, NotesTreeNode>
|
||||
): Promise<NotesTreeNode | null> {
|
||||
const fileName = (file.webkitRelativePath || file.name).split('/').pop()!
|
||||
const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '')
|
||||
|
||||
let actualDirPath = originalDirPath
|
||||
let parentNode: NotesTreeNode | null = null
|
||||
|
||||
if (originalDirPath === targetFolderPath) {
|
||||
parentNode =
|
||||
tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath)
|
||||
|
||||
if (!parentNode) {
|
||||
logger.debug(`Uploading file ${fileName} to root directory: ${targetFolderPath}`)
|
||||
}
|
||||
} else {
|
||||
parentNode = createdFolders.get(originalDirPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = tree.find((node) => node.externalPath === originalDirPath) || null
|
||||
if (!parentNode) {
|
||||
parentNode = findNodeByExternalPath(tree, originalDirPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (!parentNode) {
|
||||
for (const [originalPath, createdNode] of createdFolders.entries()) {
|
||||
if (originalPath === originalDirPath) {
|
||||
parentNode = createdNode
|
||||
actualDirPath = createdNode.externalPath
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parentNode) {
|
||||
logger.error(`Cannot upload file ${fileName}: parent node not found for path ${originalDirPath}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const { safeName, exists } = await window.api.file.checkFileName(actualDirPath, nameWithoutExt, true)
|
||||
if (exists) {
|
||||
logger.warn(`Note already exists, will be overwritten: ${safeName}`)
|
||||
}
|
||||
|
||||
const notePath = `${actualDirPath}/${safeName}${MARKDOWN_EXT}`
|
||||
|
||||
const noteId = uuidv4()
|
||||
const note: NotesTreeNode = {
|
||||
id: noteId,
|
||||
name: safeName,
|
||||
treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`,
|
||||
externalPath: notePath,
|
||||
type: 'file',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
const content = await readFileContent(file)
|
||||
await window.api.file.write(notePath, content)
|
||||
await insertNodeIntoTree(tree, note, parentNode?.id)
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
@ -283,6 +283,8 @@ class WebSearchService {
|
||||
// 2. 合并所有结果并按分数排序
|
||||
const flatResults = allResults.flat().sort((a, b) => b.score - a.score)
|
||||
|
||||
logger.debug(`Found ${flatResults.length} result(s) in search base related to question(s): `, questions)
|
||||
|
||||
// 3. 去重,保留最高分的重复内容
|
||||
const seen = new Set<string>()
|
||||
const uniqueResults = flatResults.filter((item) => {
|
||||
@ -293,6 +295,8 @@ class WebSearchService {
|
||||
return true
|
||||
})
|
||||
|
||||
logger.debug(`Found ${uniqueResults.length} unique result(s) from search base after sorting and deduplication`)
|
||||
|
||||
// 4. 转换为引用格式
|
||||
return await Promise.all(
|
||||
uniqueResults.map(async (result, index) => ({
|
||||
@ -327,12 +331,17 @@ class WebSearchService {
|
||||
Math.max(0, rawResults.length) * (config.documentCount ?? DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT)
|
||||
|
||||
const searchBase = await this.ensureSearchBase(config, totalDocumentCount, requestId)
|
||||
logger.debug('Search base for RAG compression: ', searchBase)
|
||||
|
||||
// 1. 清空知识库
|
||||
await window.api.knowledgeBase.reset(getKnowledgeBaseParams(searchBase))
|
||||
const baseParams = getKnowledgeBaseParams(searchBase)
|
||||
await window.api.knowledgeBase.reset(baseParams)
|
||||
|
||||
// 2. 一次性添加所有搜索结果到知识库
|
||||
const addPromises = rawResults.map(async (result) => {
|
||||
logger.debug('Search base parameters for RAG compression: ', baseParams)
|
||||
|
||||
// 2. 顺序添加所有搜索结果到知识库
|
||||
// FIXME: 目前的知识库 add 不支持并发
|
||||
for (const result of rawResults) {
|
||||
const item: KnowledgeItem & { sourceUrl?: string } = {
|
||||
id: uuid(),
|
||||
type: 'note',
|
||||
@ -347,10 +356,7 @@ class WebSearchService {
|
||||
base: getKnowledgeBaseParams(searchBase),
|
||||
item
|
||||
})
|
||||
})
|
||||
|
||||
// 等待所有结果添加完成
|
||||
await Promise.all(addPromises)
|
||||
}
|
||||
|
||||
// 3. 对知识库执行多问题搜索获取压缩结果
|
||||
const references = await this.querySearchBase(questions, searchBase)
|
||||
@ -475,6 +481,7 @@ class WebSearchService {
|
||||
|
||||
// 统计成功完成的搜索数量
|
||||
const successfulSearchCount = searchResults.filter((result) => result.status === 'fulfilled').length
|
||||
logger.verbose(`Successful search count: ${successfulSearchCount}`)
|
||||
if (successfulSearchCount > 1) {
|
||||
await this.setWebSearchStatus(
|
||||
requestId,
|
||||
@ -498,6 +505,12 @@ class WebSearchService {
|
||||
}
|
||||
})
|
||||
|
||||
logger.verbose(`FulFilled search result count: ${finalResults.length}`)
|
||||
logger.verbose(
|
||||
'FulFilled search result: ',
|
||||
finalResults.map(({ title, url }) => ({ title, url }))
|
||||
)
|
||||
|
||||
// 如果没有搜索结果,直接返回空结果
|
||||
if (finalResults.length === 0) {
|
||||
await this.setWebSearchStatus(requestId, { phase: 'default' })
|
||||
|
||||
@ -2,7 +2,6 @@ import type { WebSearchResultBlock } from '@anthropic-ai/sdk/resources'
|
||||
import type { GenerateImagesConfig, GroundingMetadata, PersonGeneration } from '@google/genai'
|
||||
import type OpenAI from 'openai'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
export * from './file'
|
||||
export * from './note'
|
||||
@ -837,20 +836,6 @@ export const isBuiltinMCPServerName = (name: string): name is BuiltinMCPServerNa
|
||||
return BuiltinMCPServerNamesArray.some((n) => n === name)
|
||||
}
|
||||
|
||||
export interface MCPToolInputSchema {
|
||||
type: string
|
||||
title: string
|
||||
description?: string
|
||||
required?: string[]
|
||||
properties: Record<string, object>
|
||||
}
|
||||
|
||||
export const MCPToolOutputSchema = z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.string(), z.unknown()),
|
||||
required: z.array(z.string())
|
||||
})
|
||||
|
||||
export interface MCPPromptArguments {
|
||||
name: string
|
||||
description?: string
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { isBuiltinMCPServerName } from '.'
|
||||
|
||||
@ -187,18 +187,13 @@ export const McpServerConfigSchema = z
|
||||
// 显式传入的type会覆盖掉从url推断的逻辑
|
||||
if (!schema.type) {
|
||||
const url = schema.baseUrl ?? schema.url ?? null
|
||||
// NOTE: url 暗示了服务器的类型为 streamableHttp 或 sse,未来可能会扩展其他类型
|
||||
if (url !== null) {
|
||||
if (url.endsWith('/mcp')) {
|
||||
return {
|
||||
...schema,
|
||||
type: 'streamableHttp'
|
||||
} as const
|
||||
} else if (url.endsWith('/sse')) {
|
||||
return {
|
||||
...schema,
|
||||
type: 'sse'
|
||||
} as const
|
||||
}
|
||||
const type = getMcpServerType(url)
|
||||
return {
|
||||
...schema,
|
||||
type
|
||||
} as const
|
||||
}
|
||||
}
|
||||
return schema
|
||||
@ -254,3 +249,14 @@ export function safeValidateMcpConfig(config: unknown) {
|
||||
export function safeValidateMcpServerConfig(config: unknown) {
|
||||
return McpServerConfigSchema.safeParse(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据给定的URL判断MCP服务器的类型。
|
||||
* 如果URL以 "/mcp" 结尾,则类型为 "streamableHttp",否则为 "sse"。
|
||||
*
|
||||
* @param url - 服务器的URL地址
|
||||
* @returns MCP服务器类型('streamableHttp' 或 'sse')
|
||||
*/
|
||||
export function getMcpServerType(url: string): McpServerType {
|
||||
return url.endsWith('/mcp') ? 'streamableHttp' : 'sse'
|
||||
}
|
||||
|
||||
@ -19,22 +19,24 @@ export interface BaseTool {
|
||||
// providerExecuted?: boolean // 标识是Provider端执行还是客户端执行
|
||||
// }
|
||||
|
||||
export const MCPToolOutputSchema = z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.string(), z.unknown()),
|
||||
required: z.array(z.string())
|
||||
})
|
||||
export const MCPToolOutputSchema = z
|
||||
.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.object({}).loose().optional(),
|
||||
required: z.array(z.string()).optional()
|
||||
})
|
||||
.loose()
|
||||
|
||||
export interface MCPToolInputSchema {
|
||||
type: string
|
||||
title: string
|
||||
description?: string
|
||||
required?: string[]
|
||||
properties: Record<string, object>
|
||||
}
|
||||
export const MCPToolInputSchema = z
|
||||
.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.object({}).loose().optional(),
|
||||
required: z.array(z.string()).optional()
|
||||
})
|
||||
.loose()
|
||||
|
||||
export interface BuiltinTool extends BaseTool {
|
||||
inputSchema: MCPToolInputSchema
|
||||
inputSchema: z.infer<typeof MCPToolInputSchema>
|
||||
type: 'builtin'
|
||||
}
|
||||
|
||||
@ -44,7 +46,7 @@ export interface MCPTool extends BaseTool {
|
||||
serverName: string
|
||||
name: string
|
||||
description?: string
|
||||
inputSchema: MCPToolInputSchema
|
||||
inputSchema: z.infer<typeof MCPToolInputSchema>
|
||||
outputSchema?: z.infer<typeof MCPToolOutputSchema>
|
||||
isBuiltIn?: boolean // 标识是否为内置工具,内置工具不需要通过MCP协议调用
|
||||
type: 'mcp'
|
||||
|
||||
104
src/renderer/src/utils/dataLimit.ts
Normal file
104
src/renderer/src/utils/dataLimit.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { AppInfo } from '@renderer/types'
|
||||
import { GB, MB } from '@shared/config/constant'
|
||||
import { t } from 'i18next'
|
||||
|
||||
const logger = loggerService.withContext('useDataLimit')
|
||||
|
||||
const CHECK_INTERVAL_NORMAL = 1000 * 60 * 10 // 10 minutes
|
||||
const CHECK_INTERVAL_WARNING = 1000 * 60 * 1 // 1 minute when warning is active
|
||||
|
||||
let currentInterval: NodeJS.Timeout | null = null
|
||||
let currentToastId: string | null = null
|
||||
|
||||
async function checkAppStorageQuota() {
|
||||
try {
|
||||
const { usage, quota } = await navigator.storage.estimate()
|
||||
if (usage && quota) {
|
||||
const usageInMB = (usage / MB).toFixed(2)
|
||||
const quotaInMB = (quota / MB).toFixed(2)
|
||||
const usagePercentage = (usage / quota) * 100
|
||||
|
||||
logger.info(`App storage quota: Used ${usageInMB} MB / Total ${quotaInMB} MB (${usagePercentage.toFixed(2)}%)`)
|
||||
|
||||
// if usage percentage is greater than 95%,
|
||||
// warn user to clean up app internal data
|
||||
if (usagePercentage >= 95) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get storage quota:', error as Error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function checkAppDataDiskQuota(appDataPath: string) {
|
||||
try {
|
||||
const diskInfo = await window.api.getDiskInfo(appDataPath)
|
||||
if (!diskInfo) {
|
||||
return false
|
||||
}
|
||||
const { free } = diskInfo
|
||||
logger.info(`App data disk quota: Free ${free} GB`)
|
||||
// if free is less than 1GB, return true
|
||||
return free < 1 * GB
|
||||
} catch (error) {
|
||||
logger.error('Failed to get app data disk quota:', error as Error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function checkDataLimit() {
|
||||
const check = async () => {
|
||||
let isStorageQuotaLow = false
|
||||
let isAppDataDiskQuotaLow = false
|
||||
|
||||
isStorageQuotaLow = await checkAppStorageQuota()
|
||||
|
||||
const appInfo: AppInfo = await window.api.getAppInfo()
|
||||
if (appInfo?.appDataPath) {
|
||||
isAppDataDiskQuotaLow = await checkAppDataDiskQuota(appInfo.appDataPath)
|
||||
}
|
||||
|
||||
const shouldShowWarning = isStorageQuotaLow || isAppDataDiskQuotaLow
|
||||
|
||||
// Show or hide toast based on warning state
|
||||
if (shouldShowWarning && !currentToastId) {
|
||||
// Show persistent toast without close button
|
||||
const toastId = window.toast.warning({
|
||||
title: t('settings.data.limit.appDataDiskQuota'),
|
||||
description: t('settings.data.limit.appDataDiskQuotaDescription'),
|
||||
timeout: 0, // Never auto-dismiss
|
||||
hideCloseButton: true // Hide close button so user cannot dismiss
|
||||
})
|
||||
currentToastId = toastId
|
||||
|
||||
// Switch to warning mode with shorter interval
|
||||
logger.info('Disk space low, switching to 1-minute check interval')
|
||||
if (currentInterval) {
|
||||
clearInterval(currentInterval)
|
||||
}
|
||||
currentInterval = setInterval(check, CHECK_INTERVAL_WARNING)
|
||||
} else if (!shouldShowWarning && currentToastId) {
|
||||
// Dismiss toast when space is recovered
|
||||
window.toast.closeToast(currentToastId)
|
||||
currentToastId = null
|
||||
|
||||
// Switch back to normal mode
|
||||
logger.info('Disk space recovered, switching back to 10-minute check interval')
|
||||
if (currentInterval) {
|
||||
clearInterval(currentInterval)
|
||||
}
|
||||
currentInterval = setInterval(check, CHECK_INTERVAL_NORMAL)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial check
|
||||
check()
|
||||
|
||||
// Set up initial interval (normal mode)
|
||||
if (!currentInterval) {
|
||||
currentInterval = setInterval(check, CHECK_INTERVAL_NORMAL)
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { McpError } from '@modelcontextprotocol/sdk/types.js'
|
||||
import {
|
||||
AiSdkErrorUnion,
|
||||
isSerializedAiSdkAPICallError,
|
||||
@ -41,25 +42,17 @@ export function getErrorDetails(err: any, seen = new WeakSet()): any {
|
||||
return result
|
||||
}
|
||||
|
||||
export function formatErrorMessage(error: any): string {
|
||||
try {
|
||||
const detailedError = getErrorDetails(error)
|
||||
delete detailedError?.headers
|
||||
delete detailedError?.stack
|
||||
delete detailedError?.request_id
|
||||
export function formatErrorMessage(error: unknown): string {
|
||||
const detailedError = getErrorDetails(error)
|
||||
delete detailedError?.headers
|
||||
delete detailedError?.stack
|
||||
delete detailedError?.request_id
|
||||
|
||||
const formattedJson = JSON.stringify(detailedError, null, 2)
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')
|
||||
return `Error Details:\n${formattedJson}`
|
||||
} catch (e) {
|
||||
try {
|
||||
return `Error: ${String(error)}`
|
||||
} catch {
|
||||
return 'Error: Unable to format error message'
|
||||
}
|
||||
}
|
||||
const formattedJson = JSON.stringify(detailedError, null, 2)
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')
|
||||
return `Error Details:\n${formattedJson}`
|
||||
}
|
||||
|
||||
export const isAbortError = (error: any): boolean => {
|
||||
@ -89,10 +82,8 @@ export const isAbortError = (error: any): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
export const formatMcpError = (error: any) => {
|
||||
if (error.message.includes('32000')) {
|
||||
return t('settings.mcp.errors.32000')
|
||||
}
|
||||
// TODO: format
|
||||
export const formatMcpError = (error: McpError) => {
|
||||
return error.message
|
||||
}
|
||||
|
||||
|
||||
@ -222,6 +222,7 @@ export function uniqueObjectArray<T>(array: T[]): T[] {
|
||||
|
||||
export * from './api'
|
||||
export * from './collection'
|
||||
export * from './dataLimit'
|
||||
export * from './file'
|
||||
export * from './image'
|
||||
export * from './json'
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import '@renderer/assets/styles/index.css'
|
||||
import '@renderer/assets/styles/tailwind.css'
|
||||
import '@ant-design/v5-patch-for-react-19'
|
||||
|
||||
import KeyvStorage from '@kangfenmao/keyv-storage'
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import '@renderer/assets/styles/index.css'
|
||||
import '@renderer/assets/styles/tailwind.css'
|
||||
import '@ant-design/v5-patch-for-react-19'
|
||||
|
||||
import { HeroUIProvider } from '@heroui/react'
|
||||
|
||||
18
yarn.lock
18
yarn.lock
@ -6654,9 +6654,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@modelcontextprotocol/sdk@npm:^1.17.0":
|
||||
version: 1.17.0
|
||||
resolution: "@modelcontextprotocol/sdk@npm:1.17.0"
|
||||
"@modelcontextprotocol/sdk@npm:^1.17.5":
|
||||
version: 1.17.5
|
||||
resolution: "@modelcontextprotocol/sdk@npm:1.17.5"
|
||||
dependencies:
|
||||
ajv: "npm:^6.12.6"
|
||||
content-type: "npm:^1.0.5"
|
||||
@ -6670,7 +6670,7 @@ __metadata:
|
||||
raw-body: "npm:^3.0.0"
|
||||
zod: "npm:^3.23.8"
|
||||
zod-to-json-schema: "npm:^3.24.1"
|
||||
checksum: 10c0/ac497edeb05a434bf8092475e4354ec602644b0197735d3bcd809ee1922f2078ab71e7d8d9dbe1c42765978fa3f2f807df01a2a3ad421c986f0b2207c3a40a68
|
||||
checksum: 10c0/182b92b5e7c07da428fd23c6de22021c4f9a91f799c02a8ef15def07e4f9361d0fc22303548658fec2a700623535fd44a9dc4d010fb5d803a8f80e3c6c64a45e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -13067,7 +13067,7 @@ __metadata:
|
||||
"@libsql/client": "npm:0.14.0"
|
||||
"@libsql/win32-x64-msvc": "npm:^0.4.7"
|
||||
"@mistralai/mistralai": "npm:^1.7.5"
|
||||
"@modelcontextprotocol/sdk": "npm:^1.17.0"
|
||||
"@modelcontextprotocol/sdk": "npm:^1.17.5"
|
||||
"@mozilla/readability": "npm:^0.6.0"
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch"
|
||||
"@notionhq/client": "npm:^2.2.15"
|
||||
@ -13150,6 +13150,7 @@ __metadata:
|
||||
axios: "npm:^1.7.3"
|
||||
browser-image-compression: "npm:^2.0.2"
|
||||
chardet: "npm:^2.1.0"
|
||||
check-disk-space: "npm:3.4.0"
|
||||
cheerio: "npm:^1.1.2"
|
||||
chokidar: "npm:^4.0.3"
|
||||
cli-progress: "npm:^3.12.0"
|
||||
@ -14613,6 +14614,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"check-disk-space@npm:3.4.0":
|
||||
version: 3.4.0
|
||||
resolution: "check-disk-space@npm:3.4.0"
|
||||
checksum: 10c0/cc39c91e1337e974fb5069c2fbd9eb92aceca6e35f3da6863a4eada58f15c1bf6970055bffed1e41c15cde1fd0ad2580bb99bef8275791ed56d69947f8657aa5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"check-error@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "check-error@npm:2.1.1"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user