diff --git a/electron-builder.yml b/electron-builder.yml index 8c339b8bc4..7cc8ffe1f9 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -85,9 +85,7 @@ afterPack: scripts/after-pack.js afterSign: scripts/notarize.js releaseInfo: releaseNotes: | - 引入全新的 QuickPanel 功能,统一了应用内的输入和搜索操作 - 新增内存 MCP (in-memory MCP) 服务支持及配置管理 - 新增对七牛云 AI (Qiniu AI) 提供商的支持 - 添加了导出菜单选项设置和思维链(Chain-of-Thought)导出功能 - 消息锚点线支持底部锚点 - 为 AppImage 添加了多分辨率图标 + 知识库和服务商界面更新 + 增加 Dangbei 小程序 + 可以强制使用搜索引擎覆盖模型自带搜索能力 + 修复部分公式无法正常渲染问题 diff --git a/package.json b/package.json index 27e6eb1775..2cbbf43f39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.1.19", + "version": "1.2.1", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -72,6 +72,7 @@ "@types/react-infinite-scroll-component": "^5.0.0", "@xyflow/react": "^12.4.4", "adm-zip": "^0.5.16", + "color": "^5.0.0", "diff": "^7.0.0", "docx": "^9.0.2", "electron-log": "^5.1.5", @@ -88,6 +89,7 @@ "officeparser": "^4.1.1", "proxy-agent": "^6.5.0", "tar": "^7.4.3", + "tiny-pinyin": "^1.3.2", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", "undici": "^7.4.0", @@ -111,7 +113,7 @@ "@google/genai": "^0.4.0", "@hello-pangea/dnd": "^16.6.0", "@kangfenmao/keyv-storage": "^0.1.0", - "@modelcontextprotocol/sdk": "^1.8.0", + "@modelcontextprotocol/sdk": "^1.9.0", "@notionhq/client": "^2.2.15", "@reduxjs/toolkit": "^2.2.5", "@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch", diff --git a/src/main/reranker/BaseReranker.ts b/src/main/reranker/BaseReranker.ts index 58a6426914..4109f53986 100644 --- a/src/main/reranker/BaseReranker.ts +++ b/src/main/reranker/BaseReranker.ts @@ -3,14 +3,60 @@ import { KnowledgeBaseParams } from '@types' export default abstract class BaseReranker { protected base: KnowledgeBaseParams + constructor(base: KnowledgeBaseParams) { if (!base.rerankModel) { throw new Error('Rerank model is required') } this.base = base } + abstract rerank(query: string, searchResults: ExtractChunkData[]): Promise + /** + * Get Rerank Request Url + */ + protected getRerankUrl() { + let baseURL = this.base?.rerankBaseURL?.endsWith('/') + ? this.base.rerankBaseURL.slice(0, -1) + : this.base.rerankBaseURL + // 必须携带/v1,否则会404 + if (baseURL && !baseURL.endsWith('/v1')) { + baseURL = `${baseURL}/v1` + } + + return `${baseURL}/rerank` + } + + /** + * Get Rerank Result + * @param searchResults + * @param rerankResults + * @protected + */ + protected getRerankResult( + searchResults: ExtractChunkData[], + rerankResults: Array<{ + index: number + relevance_score: number + }> + ) { + const resultMap = new Map(rerankResults.map((result) => [result.index, result.relevance_score || 0])) + + return searchResults + .map((doc: ExtractChunkData, index: number) => { + const score = resultMap.get(index) + if (score === undefined) return undefined + + return { + ...doc, + score + } + }) + .filter((doc): doc is ExtractChunkData => doc !== undefined) + .sort((a, b) => b.score - a.score) + } + public defaultHeaders() { return { Authorization: `Bearer ${this.base.rerankApiKey}`, @@ -18,7 +64,7 @@ export default abstract class BaseReranker { } } - public formatErrorMessage(url: string, error: any, requestBody: any) { + protected formatErrorMessage(url: string, error: any, requestBody: any) { const errorDetails = { url: url, message: error.message, diff --git a/src/main/reranker/JinaReranker.ts b/src/main/reranker/JinaReranker.ts index 718774ee22..207ddcb992 100644 --- a/src/main/reranker/JinaReranker.ts +++ b/src/main/reranker/JinaReranker.ts @@ -10,16 +10,7 @@ export default class JinaReranker extends BaseReranker { } public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { - let baseURL = this.base?.rerankBaseURL?.endsWith('/') - ? this.base.rerankBaseURL.slice(0, -1) - : this.base.rerankBaseURL - - // 必须携带/v1,否则会404 - if (baseURL && !baseURL.endsWith('/v1')) { - baseURL = `${baseURL}/v1` - } - - const url = `${baseURL}/rerank` + const url = this.getRerankUrl() const requestBody = { model: this.base.rerankModel, @@ -32,23 +23,9 @@ export default class JinaReranker extends BaseReranker { const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() }) const rerankResults = data.results - console.log(rerankResults) - const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0])) - return searchResults - .map((doc: ExtractChunkData, index: number) => { - const score = resultMap.get(index) - if (score === undefined) return undefined - - return { - ...doc, - score - } - }) - .filter((doc): doc is ExtractChunkData => doc !== undefined) - .sort((a, b) => b.score - a.score) + return this.getRerankResult(searchResults, rerankResults) } catch (error: any) { const errorDetails = this.formatErrorMessage(url, error, requestBody) - console.error('Jina Reranker API Error:', errorDetails) throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`) } diff --git a/src/main/reranker/SiliconFlowReranker.ts b/src/main/reranker/SiliconFlowReranker.ts index d37f547b24..0a27cf7e2a 100644 --- a/src/main/reranker/SiliconFlowReranker.ts +++ b/src/main/reranker/SiliconFlowReranker.ts @@ -10,16 +10,7 @@ export default class SiliconFlowReranker extends BaseReranker { } public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { - let baseURL = this.base?.rerankBaseURL?.endsWith('/') - ? this.base.rerankBaseURL.slice(0, -1) - : this.base.rerankBaseURL - - // 必须携带/v1,否则会404 - if (baseURL && !baseURL.endsWith('/v1')) { - baseURL = `${baseURL}/v1` - } - - const url = `${baseURL}/rerank` + const url = this.getRerankUrl() const requestBody = { model: this.base.rerankModel, @@ -34,20 +25,7 @@ export default class SiliconFlowReranker extends BaseReranker { const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() }) const rerankResults = data.results - const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0])) - - return searchResults - .map((doc: ExtractChunkData, index: number) => { - const score = resultMap.get(index) - if (score === undefined) return undefined - - return { - ...doc, - score - } - }) - .filter((doc): doc is ExtractChunkData => doc !== undefined) - .sort((a, b) => b.score - a.score) + return this.getRerankResult(searchResults, rerankResults) } catch (error: any) { const errorDetails = this.formatErrorMessage(url, error, requestBody) diff --git a/src/main/reranker/VoyageReranker.ts b/src/main/reranker/VoyageReranker.ts index 0cfc024eee..a2c0f5f8af 100644 --- a/src/main/reranker/VoyageReranker.ts +++ b/src/main/reranker/VoyageReranker.ts @@ -10,15 +10,7 @@ export default class VoyageReranker extends BaseReranker { } public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { - let baseURL = this.base?.rerankBaseURL?.endsWith('/') - ? this.base.rerankBaseURL.slice(0, -1) - : this.base.rerankBaseURL - - if (baseURL && !baseURL.endsWith('/v1')) { - baseURL = `${baseURL}/v1` - } - - const url = `${baseURL}/rerank` + const url = this.getRerankUrl() const requestBody = { model: this.base.rerankModel, @@ -37,21 +29,7 @@ export default class VoyageReranker extends BaseReranker { }) const rerankResults = data.data - - const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0])) - - return searchResults - .map((doc: ExtractChunkData, index: number) => { - const score = resultMap.get(index) - if (score === undefined) return undefined - - return { - ...doc, - score - } - }) - .filter((doc): doc is ExtractChunkData => doc !== undefined) - .sort((a, b) => b.score - a.score) + return this.getRerankResult(searchResults, rerankResults) } catch (error: any) { const errorDetails = this.formatErrorMessage(url, error, requestBody) diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index b226251365..aef46e9ba0 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -15,6 +15,7 @@ import { app } from 'electron' import Logger from 'electron-log' import { CacheService } from './CacheService' +import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient' class McpService { private clients: Map = new Map() @@ -46,24 +47,28 @@ class McpService { // Check if we already have a client for this server configuration const existingClient = this.clients.get(serverKey) if (existingClient) { - // Check if the existing client is still connected - const pingResult = await existingClient.ping() - Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult) - // If the ping fails, remove the client from the cache - // and create a new one - if (!pingResult) { + try { + // Check if the existing client is still connected + const pingResult = await existingClient.ping() + Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult) + // If the ping fails, remove the client from the cache + // and create a new one + if (!pingResult) { + this.clients.delete(serverKey) + } else { + return existingClient + } + } catch (error) { + Logger.error(`[MCP] Error pinging server ${server.name}:`, error) this.clients.delete(serverKey) - } else { - return existingClient } } - // Create new client instance for each connection const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} }) const args = [...(server.args || [])] - let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport + let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport try { // Create appropriate transport based on configuration @@ -82,7 +87,16 @@ class McpService { // set the client transport to the client transport = clientTransport } else if (server.baseUrl) { - transport = new SSEClientTransport(new URL(server.baseUrl)) + if (server.type === 'streamableHttp') { + transport = new StreamableHTTPClientTransport( + new URL(server.baseUrl!), + {} as StreamableHTTPClientTransportOptions + ) + } else if (server.type === 'sse') { + transport = new SSEClientTransport(new URL(server.baseUrl!)) + } else { + throw new Error('Invalid server type') + } } else if (server.command) { let cmd = server.command diff --git a/src/main/services/MCPStreamableHttpClient.ts b/src/main/services/MCPStreamableHttpClient.ts new file mode 100644 index 0000000000..1e080d2a71 --- /dev/null +++ b/src/main/services/MCPStreamableHttpClient.ts @@ -0,0 +1,365 @@ +import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js' + +export class StreamableHTTPError extends Error { + constructor( + public readonly code: number | undefined, + message: string | undefined, + public readonly event: ErrorEvent + ) { + super(`Streamable HTTP error: ${message}`) + } +} + +/** + * Configuration options for the `StreamableHTTPClientTransport`. + */ +export type StreamableHTTPClientTransportOptions = { + /** + * An OAuth client provider to use for authentication. + * + * When an `authProvider` is specified and the connection is started: + * 1. The connection is attempted with any existing access token from the `authProvider`. + * 2. If the access token has expired, the `authProvider` is used to refresh the token. + * 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`. + * + * After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `StreamableHTTPClientTransport.finishAuth` with the authorization code before retrying the connection. + * + * If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown. + * + * `UnauthorizedError` might also be thrown when sending any message over the transport, indicating that the session has expired, and needs to be re-authed and reconnected. + */ + authProvider?: OAuthClientProvider + + /** + * Customizes HTTP requests to the server. + */ + requestInit?: RequestInit +} + +/** + * Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. + * It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events + * for receiving messages. + */ +export class StreamableHTTPClientTransport implements Transport { + private _activeStreams: Map> = new Map() + private _abortController?: AbortController + private _url: URL + private _requestInit?: RequestInit + private _authProvider?: OAuthClientProvider + private _sessionId?: string + private _lastEventId?: string + + onclose?: () => void + onerror?: (error: Error) => void + onmessage?: (message: JSONRPCMessage) => void + + constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) { + this._url = url + this._requestInit = opts?.requestInit + this._authProvider = opts?.authProvider + } + + private async _authThenStart(): Promise { + if (!this._authProvider) { + throw new UnauthorizedError('No auth provider') + } + + let result: AuthResult + try { + result = await auth(this._authProvider, { serverUrl: this._url }) + } catch (error) { + this.onerror?.(error as Error) + throw error + } + + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError() + } + + return await this._startOrAuth() + } + + private async _commonHeaders(): Promise { + const headers: HeadersInit = {} + if (this._authProvider) { + const tokens = await this._authProvider.tokens() + if (tokens) { + headers['Authorization'] = `Bearer ${tokens.access_token}` + } + } + + if (this._sessionId) { + headers['mcp-session-id'] = this._sessionId + } + + return headers + } + + private async _startOrAuth(): Promise { + try { + // Try to open an initial SSE stream with GET to listen for server messages + // This is optional according to the spec - server may not support it + const commonHeaders = await this._commonHeaders() + const headers = new Headers(commonHeaders) + headers.set('Accept', 'text/event-stream') + + // Include Last-Event-ID header for resumable streams + if (this._lastEventId) { + headers.set('last-event-id', this._lastEventId) + } + + const response = await fetch(this._url, { + method: 'GET', + headers, + signal: this._abortController?.signal + }) + + if (response.status === 405) { + // Server doesn't support GET for SSE, which is allowed by the spec + // We'll rely on SSE responses to POST requests for communication + return + } + + if (!response.ok) { + if (response.status === 401 && this._authProvider) { + // Need to authenticate + return await this._authThenStart() + } + + const error = new Error(`Failed to open SSE stream: ${response.status} ${response.statusText}`) + this.onerror?.(error) + throw error + } + + // Successful connection, handle the SSE stream as a standalone listener + const streamId = `initial-${Date.now()}` + this._handleSseStream(response.body, streamId) + } catch (error) { + this.onerror?.(error as Error) + throw error + } + } + + async start() { + if (this._activeStreams.size > 0) { + throw new Error( + 'StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.' + ) + } + + this._abortController = new AbortController() + return await this._startOrAuth() + } + + /** + * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + */ + async finishAuth(authorizationCode: string): Promise { + if (!this._authProvider) { + throw new UnauthorizedError('No auth provider') + } + + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode }) + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError('Failed to authorize') + } + } + + async close(): Promise { + // Close all active streams + for (const reader of this._activeStreams.values()) { + try { + reader.cancel() + } catch (error) { + this.onerror?.(error as Error) + } + } + this._activeStreams.clear() + + // Abort any pending requests + this._abortController?.abort() + + // If we have a session ID, send a DELETE request to explicitly terminate the session + if (this._sessionId) { + try { + const commonHeaders = await this._commonHeaders() + const response = await fetch(this._url, { + method: 'DELETE', + headers: commonHeaders, + signal: this._abortController?.signal + }) + + if (!response.ok) { + // Server might respond with 405 if it doesn't support explicit session termination + // We don't throw an error in that case + if (response.status !== 405) { + const text = await response.text().catch(() => null) + throw new Error(`Error terminating session (HTTP ${response.status}): ${text}`) + } + } + } catch (error) { + // We still want to invoke onclose even if the session termination fails + this.onerror?.(error as Error) + } + } + + this.onclose?.() + } + + async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise { + try { + const commonHeaders = await this._commonHeaders() + const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers }) + headers.set('content-type', 'application/json') + headers.set('accept', 'application/json, text/event-stream') + + const init = { + ...this._requestInit, + method: 'POST', + headers, + body: JSON.stringify(message), + signal: this._abortController?.signal + } + + const response = await fetch(this._url, init) + + // Handle session ID received during initialization + const sessionId = response.headers.get('mcp-session-id') + if (sessionId) { + this._sessionId = sessionId + } + + if (!response.ok) { + if (response.status === 401 && this._authProvider) { + const result = await auth(this._authProvider, { serverUrl: this._url }) + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError() + } + + // Purposely _not_ awaited, so we don't call onerror twice + return this.send(message) + } + + const text = await response.text().catch(() => null) + throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`) + } + + // If the response is 202 Accepted, there's no body to process + if (response.status === 202) { + return + } + + // Get original message(s) for detecting request IDs + const messages = Array.isArray(message) ? message : [message] + + // Extract IDs from request messages for tracking responses + const requestIds = messages + .filter((msg) => 'method' in msg && 'id' in msg) + .map((msg) => ('id' in msg ? msg.id : undefined)) + .filter((id) => id !== undefined) + + // If we have request IDs and an SSE response, create a unique stream ID + const hasRequests = requestIds.length > 0 + + // Check the response type + const contentType = response.headers.get('content-type') + + if (hasRequests) { + if (contentType?.includes('text/event-stream')) { + // For streaming responses, create a unique stream ID based on request IDs + const streamId = `req-${requestIds.join('-')}-${Date.now()}` + this._handleSseStream(response.body, streamId) + } else if (contentType?.includes('application/json')) { + // For non-streaming servers, we might get direct JSON responses + const data = await response.json() + const responseMessages = Array.isArray(data) + ? data.map((msg) => JSONRPCMessageSchema.parse(msg)) + : [JSONRPCMessageSchema.parse(data)] + + for (const msg of responseMessages) { + this.onmessage?.(msg) + } + } + } + } catch (error) { + this.onerror?.(error as Error) + throw error + } + } + + private _handleSseStream(stream: ReadableStream | null, streamId: string): void { + if (!stream) { + return + } + + // Set up stream handling for server-sent events + const reader = stream.getReader() + this._activeStreams.set(streamId, reader) + const decoder = new TextDecoder() + let buffer = '' + + const processStream = async () => { + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + // Stream closed by server + this._activeStreams.delete(streamId) + break + } + + buffer += decoder.decode(value, { stream: true }) + + // Process SSE messages in the buffer + const events = buffer.split('\n\n') + buffer = events.pop() || '' + + for (const event of events) { + const lines = event.split('\n') + let id: string | undefined + let eventType: string | undefined + let data: string | undefined + + // Parse SSE message according to the format + for (const line of lines) { + if (line.startsWith('id:')) { + id = line.slice(3).trim() + } else if (line.startsWith('event:')) { + eventType = line.slice(6).trim() + } else if (line.startsWith('data:')) { + data = line.slice(5).trim() + } + } + + // Update last event ID if provided by server + // As per spec: the ID MUST be globally unique across all streams within that session + if (id) { + this._lastEventId = id + } + + // Handle message event + if (data) { + // Default event type is 'message' per SSE spec if not specified + if (!eventType || eventType === 'message') { + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(data)) + this.onmessage?.(message) + } catch (error) { + this.onerror?.(error as Error) + } + } + } + } + } + } catch (error) { + this._activeStreams.delete(streamId) + this.onerror?.(error as Error) + } + } + + processStream() + } +} diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 1e8d071b3c..89c9650348 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -319,10 +319,18 @@ export class WindowService { //[macOS] Known Issue // setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows) // AppleScript may be a solution, but it's not worth - this.mainWindow.setVisibleOnAllWorkspaces(true) + + // [Linux] Known Issue + // setVisibleOnAllWorkspaces 在 Linux 环境下(特别是 KDE Wayland)会导致窗口进入"假弹出"状态 + // 因此在 Linux 环境下不执行这两行代码 + if (!isLinux) { + this.mainWindow.setVisibleOnAllWorkspaces(true) + } this.mainWindow.show() this.mainWindow.focus() - this.mainWindow.setVisibleOnAllWorkspaces(false) + if (!isLinux) { + this.mainWindow.setVisibleOnAllWorkspaces(false) + } } else { this.mainWindow = this.createMainWindow() } diff --git a/src/renderer/src/assets/images/apps/dangbei.jpg b/src/renderer/src/assets/images/apps/dangbei.jpg new file mode 100644 index 0000000000..47cc74a0aa Binary files /dev/null and b/src/renderer/src/assets/images/apps/dangbei.jpg differ diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 09659681fe..13eb0f9a0e 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -36,7 +36,7 @@ --color-text: var(--color-text-1); --color-icon: #ffffff99; --color-icon-white: #ffffff; - --color-border: #ffffff15; + --color-border: #ffffff19; --color-border-soft: #ffffff10; --color-border-mute: #ffffff05; --color-error: #f44336; @@ -80,7 +80,7 @@ body { body[theme-mode='light'] { --color-white: #ffffff; - --color-white-soft: #f2f2f2; + --color-white-soft: rgba(0, 0, 0, 0.04); --color-white-mute: #eee; --color-black: #1b1b1f; @@ -108,7 +108,7 @@ body[theme-mode='light'] { --color-text: var(--color-text-1); --color-icon: #00000099; --color-icon-white: #000000; - --color-border: #00000015; + --color-border: #00000019; --color-border-soft: #00000010; --color-border-mute: #00000005; --color-error: #f44336; diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index bcafec7a17..804c03525c 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -5,9 +5,21 @@ interface CustomCollapseProps { label: React.ReactNode extra: React.ReactNode children: React.ReactNode + destroyInactivePanel?: boolean + defaultActiveKey?: string[] + activeKey?: string[] + collapsible?: 'header' | 'icon' | 'disabled' } -const CustomCollapse: FC = ({ label, extra, children }) => { +const CustomCollapse: FC = ({ + label, + extra, + children, + destroyInactivePanel = false, + defaultActiveKey = ['1'], + activeKey, + collapsible = undefined +}) => { const CollapseStyle = { width: '100%', background: 'transparent', @@ -17,7 +29,10 @@ const CustomCollapse: FC = ({ label, extra, children }) => header: { padding: '8px 16px', alignItems: 'center', - justifyContent: 'space-between' + justifyContent: 'space-between', + background: 'var(--color-background-soft)', + borderTopLeftRadius: '8px', + borderTopRightRadius: '8px' }, body: { borderTop: '0.5px solid var(--color-border)' @@ -27,7 +42,10 @@ const CustomCollapse: FC = ({ label, extra, children }) => void +} + +const CustomTag: FC = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => { + return ( + + + {icon && icon} {children} + {closable && } + + + ) +} + +export default CustomTag + +const Tag = styled.div<{ $color: string; $size: number; $closable: boolean }>` + display: inline-flex; + align-items: center; + gap: 4px; + padding: ${({ $size }) => $size / 3}px ${({ $size }) => $size * 0.8}px; + padding-right: ${({ $closable, $size }) => ($closable ? $size * 1.8 : $size * 0.8)}px; + border-radius: 99px; + color: ${({ $color }) => $color}; + background-color: ${({ $color }) => $color + '20'}; + font-size: ${({ $size }) => $size}px; + line-height: 1; + white-space: nowrap; + position: relative; + .iconfont { + font-size: ${({ $size }) => $size}px; + color: ${({ $color }) => $color}; + } +` + +const CloseIcon = styled(CloseOutlined)<{ $size: number; $color: string }>` + cursor: pointer; + font-size: ${({ $size }) => $size * 0.8}px; + color: ${({ $color }) => $color}; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + right: ${({ $size }) => $size * 0.2}px; + top: ${({ $size }) => $size * 0.2}px; + bottom: ${({ $size }) => $size * 0.2}px; + border-radius: 99px; + transition: all 0.2s ease; + aspect-ratio: 1; + line-height: 1; + &:hover { + background-color: #da8a8a; + color: #ffffff; + } +` diff --git a/src/renderer/src/components/DividerWithText.tsx b/src/renderer/src/components/DividerWithText.tsx index 0a16089495..764550381f 100644 --- a/src/renderer/src/components/DividerWithText.tsx +++ b/src/renderer/src/components/DividerWithText.tsx @@ -1,13 +1,14 @@ -import React from 'react' +import React, { CSSProperties } from 'react' import styled from 'styled-components' interface DividerWithTextProps { text: string + style?: CSSProperties } -const DividerWithText: React.FC = ({ text }) => { +const DividerWithText: React.FC = ({ text, style }) => { return ( - + {text} diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx new file mode 100644 index 0000000000..e6521a97c7 --- /dev/null +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -0,0 +1,13 @@ +import { SVGProps } from 'react' + +export const StreamlineGoodHealthAndWellBeing = (props: SVGProps) => { + return ( + + {/* Icon from Streamline by Streamline - https://creativecommons.org/licenses/by/4.0/ */} + + + + + + ) +} diff --git a/src/renderer/src/components/ListItem/index.tsx b/src/renderer/src/components/ListItem/index.tsx index 7e2165c5d3..56c0534611 100644 --- a/src/renderer/src/components/ListItem/index.tsx +++ b/src/renderer/src/components/ListItem/index.tsx @@ -70,7 +70,7 @@ const TextContainer = styled.div` overflow: hidden; ` -const TitleText = styled.div` +const TitleText = styled.div<{ $active?: boolean }>` overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/src/renderer/src/components/ModelTagsWithLabel.tsx b/src/renderer/src/components/ModelTagsWithLabel.tsx new file mode 100644 index 0000000000..b6cfa1a7cf --- /dev/null +++ b/src/renderer/src/components/ModelTagsWithLabel.tsx @@ -0,0 +1,131 @@ +import { EyeOutlined, GlobalOutlined, ToolOutlined } from '@ant-design/icons' +import { + isEmbeddingModel, + isFunctionCallingModel, + isReasoningModel, + isRerankModel, + isVisionModel, + isWebSearchModel +} from '@renderer/config/models' +import i18n from '@renderer/i18n' +import { Model } from '@renderer/types' +import { isFreeModel } from '@renderer/utils' +import { FC, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import CustomTag from './CustomTag' + +interface ModelTagsProps { + model: Model + showFree?: boolean + showReasoning?: boolean + showToolsCalling?: boolean + size?: number + showLabel?: boolean + style?: React.CSSProperties +} + +const ModelTagsWithLabel: FC = ({ + model, + showFree = true, + showReasoning = true, + showToolsCalling = true, + size = 12, + showLabel = true, + style +}) => { + const { t } = useTranslation() + const [_showLabel, _setShowLabel] = useState(showLabel) + const containerRef = useRef(null) + const resizeObserver = useRef(null) + + useEffect(() => { + if (!showLabel) return + + if (containerRef.current) { + const currentElement = containerRef.current + resizeObserver.current = new ResizeObserver((entries) => { + const maxWidth = i18n.language.startsWith('zh') ? 300 : 350 + + for (const entry of entries) { + const { width } = entry.contentRect + _setShowLabel(width >= maxWidth) + } + }) + resizeObserver.current.observe(currentElement) + + return () => { + if (resizeObserver.current) { + resizeObserver.current.unobserve(currentElement) + } + } + } + + return undefined + }, [showLabel]) + + return ( + + {isVisionModel(model) && ( + } + tooltip={t('models.type.vision')}> + {_showLabel ? t('models.type.vision') : ''} + + )} + {isWebSearchModel(model) && ( + } + tooltip={t('models.type.websearch')}> + {_showLabel ? t('models.type.websearch') : ''} + + )} + {showReasoning && isReasoningModel(model) && ( + } + tooltip={t('models.type.reasoning')}> + {_showLabel ? t('models.type.reasoning') : ''} + + )} + {showToolsCalling && isFunctionCallingModel(model) && ( + } + tooltip={t('models.type.function_calling')}> + {_showLabel ? t('models.type.function_calling') : ''} + + )} + {isEmbeddingModel(model) && ( + + )} + {showFree && isFreeModel(model) && ( + + )} + {isRerankModel(model) && ( + + )} + + ) +} + +const Container = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + flex-wrap: nowrap; + overflow-x: scroll; + &::-webkit-scrollbar { + display: none; + } +` + +export default ModelTagsWithLabel diff --git a/src/renderer/src/components/Popups/SelectModelPopup.tsx b/src/renderer/src/components/Popups/SelectModelPopup.tsx index 08008f550f..e9d3adcd10 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup.tsx @@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { HStack } from '../Layout' -import ModelTags from '../ModelTags' +import ModelTagsWithLabel from '../ModelTagsWithLabel' import Scrollbar from '../Scrollbar' type MenuItem = Required['items'][number] @@ -130,7 +130,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { label: ( - {m?.name} + {m?.name} { @@ -184,7 +184,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { {m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name} {' '} - + { @@ -481,6 +481,10 @@ const StyledMenu = styled(Menu)` } } } + + .anticon { + min-width: auto; + } } ` diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts index c26a7842ef..e122aa1d29 100644 --- a/src/renderer/src/components/QuickPanel/types.ts +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -1,6 +1,6 @@ import React from 'react' -export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | string | undefined +export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined export type QuickPanelCallBackOptions = { symbol: string action: QuickPanelCloseAction diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index d41c18716e..d89ccc3ac6 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -2,9 +2,12 @@ import { CheckOutlined, RightOutlined } from '@ant-design/icons' import { isMac } from '@renderer/config/constant' import { classNames } from '@renderer/utils' import { Flex } from 'antd' +import { theme } from 'antd' +import Color from 'color' import { t } from 'i18next' import React, { use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import styled from 'styled-components' +import * as tinyPinyin from 'tiny-pinyin' import { QuickPanelContext } from './provider' import { QuickPanelCallBackOptions, QuickPanelCloseAction, QuickPanelListItem, QuickPanelOpenOptions } from './types' @@ -27,13 +30,19 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { throw new Error('QuickPanel must be used within a QuickPanelProvider') } + const { token } = theme.useToken() + const colorPrimary = Color(token.colorPrimary || '#008000') + const selectedColor = colorPrimary.alpha(0.15).toString() + const selectedColorHover = colorPrimary.alpha(0.2).toString() + const ASSISTIVE_KEY = isMac ? '⌘' : 'Ctrl' const [isAssistiveKeyPressed, setIsAssistiveKeyPressed] = useState(false) // 避免上下翻页时,鼠标干扰 const [isMouseOver, setIsMouseOver] = useState(false) - const [index, setIndex] = useState(ctx.defaultIndex) + const [_index, setIndex] = useState(ctx.defaultIndex) + const index = useDeferredValue(_index) const [historyPanel, setHistoryPanel] = useState([]) const bodyRef = useRef(null) @@ -65,7 +74,21 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { filterText += item.description } - return filterText.toLowerCase().includes(_searchText.toLowerCase()) + const lowerFilterText = filterText.toLowerCase() + const lowerSearchText = _searchText.toLowerCase() + + if (lowerFilterText.includes(lowerSearchText)) { + return true + } + + if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) { + const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true) + if (pinyinText.toLowerCase().includes(lowerSearchText)) { + return true + } + } + + return false }) setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1) @@ -120,7 +143,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { if (textArea) { setInputText(textArea.value) } - } else if (action && !['outsideclick', 'esc'].includes(action)) { + } else if (action && !['outsideclick', 'esc', 'enter_empty'].includes(action)) { clearSearchText(true) } }, @@ -175,6 +198,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { }, [searchText]) // 获取当前输入的搜索词 + const isComposing = useRef(false) useEffect(() => { if (!ctx.isVisible) return @@ -196,11 +220,25 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { } } + const handleCompositionUpdate = () => { + isComposing.current = true + } + + const handleCompositionEnd = () => { + isComposing.current = false + } + textArea.addEventListener('input', handleInput) + textArea.addEventListener('compositionupdate', handleCompositionUpdate) + textArea.addEventListener('compositionend', handleCompositionEnd) return () => { textArea.removeEventListener('input', handleInput) - setSearchText('') + textArea.removeEventListener('compositionupdate', handleCompositionUpdate) + textArea.removeEventListener('compositionend', handleCompositionEnd) + setTimeout(() => { + setSearchText('') + }, 200) // 等待面板关闭动画结束后,再清空搜索词 } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ctx.isVisible]) @@ -236,7 +274,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { } } - if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) { + if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Escape'].includes(e.key)) { e.preventDefault() e.stopPropagation() setIsMouseOver(false) @@ -312,8 +350,16 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { break case 'Enter': + if (isComposing.current) return + if (list?.[index]) { + e.preventDefault() + e.stopPropagation() + setIsMouseOver(false) + handleItemAction(list[index], 'enter') + } else { + handleClose('enter_empty') } break case 'Escape': @@ -366,7 +412,11 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { }, [ctx.isVisible]) return ( - + setIsMouseOver(true)}> {list.map((item, i) => ( @@ -450,9 +500,14 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { ) } -const QuickPanelContainer = styled.div<{ $pageSize: number }>` +const QuickPanelContainer = styled.div<{ + $pageSize: number + $selectedColor: string + $selectedColorHover: string +}>` --focused-color: rgba(0, 0, 0, 0.06); - --selected-color: rgba(0, 0, 0, 0.03); + --selected-color: ${(props) => props.$selectedColor}; + --selected-color-dark: ${(props) => props.$selectedColorHover}; max-height: 0; position: absolute; top: 1px; @@ -465,26 +520,35 @@ const QuickPanelContainer = styled.div<{ $pageSize: number }>` transition: max-height 0.2s ease; overflow: hidden; pointer-events: none; + &.visible { pointer-events: auto; max-height: ${(props) => props.$pageSize * 31 + 100}px; } body[theme-mode='dark'] & { --focused-color: rgba(255, 255, 255, 0.1); - --selected-color: rgba(255, 255, 255, 0.03); } ` const QuickPanelBody = styled.div` - background-color: rgba(240, 240, 240, 0.5); - backdrop-filter: blur(35px) saturate(150%); border-radius: 8px 8px 0 0; padding: 5px 0; border-width: 0.5px 0.5px 0 0.5px; border-style: solid; border-color: var(--color-border); - body[theme-mode='dark'] & { - background-color: rgba(40, 40, 40, 0.4); + position: relative; + + &::before { + content: ''; + position: absolute; + inset: 0; + background-color: rgba(240, 240, 240, 0.5); + backdrop-filter: blur(35px) saturate(150%); + z-index: -1; + + body[theme-mode='dark'] & { + background-color: rgba(40, 40, 40, 0.4); + } } ` @@ -541,6 +605,9 @@ const QuickPanelItem = styled.div` margin-bottom: 1px; &.selected { background-color: var(--selected-color); + &.focused { + background-color: var(--selected-color-dark); + } } &.focused { background-color: var(--focused-color); diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index 1d99fe1f83..fb438634a8 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -6,6 +6,7 @@ import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp?url' import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg?url' import CiciAppLogo from '@renderer/assets/images/apps/cici.webp?url' import CozeAppLogo from '@renderer/assets/images/apps/coze.webp?url' +import DangbeiLogo from '@renderer/assets/images/apps/dangbei.jpg?url' import DevvAppLogo from '@renderer/assets/images/apps/devv.png?url' import DifyAppLogo from '@renderer/assets/images/apps/dify.svg?url' import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png?url' @@ -384,5 +385,12 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [ logo: ZhihuAppLogo, url: 'https://zhida.zhihu.com/', bodered: true + }, + { + id: 'dangbei', + name: '当贝AI', + logo: DangbeiLogo, + url: 'https://ai.dangbei.com/', + bodered: true } ] diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 40a215fd80..8f2170e567 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -130,6 +130,7 @@ import XirangModelLogoDark from '@renderer/assets/images/models/xirang_dark.png' import YiModelLogo from '@renderer/assets/images/models/yi.png' import YiModelLogoDark from '@renderer/assets/images/models/yi_dark.png' import { getProviderByModel } from '@renderer/services/AssistantService' +import WebSearchService from '@renderer/services/WebSearchService' import { Assistant, Model } from '@renderer/types' import OpenAI from 'openai' @@ -2244,7 +2245,7 @@ export function isWebSearchModel(model: Model): boolean { return true } - return false + return model.type?.includes('web_search') || false } export function isGenerateImageModel(model: Model): boolean { @@ -2270,6 +2271,9 @@ export function isGenerateImageModel(model: Model): boolean { } export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Record { + if (WebSearchService.isWebSearchEnabled() && WebSearchService.isOverwriteEnabled()) { + return {} + } if (isWebSearchModel(model)) { if (assistant.enableWebSearch) { const webSearchTools = getWebSearchTools(model) diff --git a/src/renderer/src/config/tools.ts b/src/renderer/src/config/tools.ts index 12ff66766a..dcb16b8033 100644 --- a/src/renderer/src/config/tools.ts +++ b/src/renderer/src/config/tools.ts @@ -27,12 +27,15 @@ export function getWebSearchTools(model: Model): ChatCompletionTool[] { ] } - return [ - { - type: 'function', - function: { - name: 'googleSearch' + if (model?.id.includes('gemini')) { + return [ + { + type: 'function', + function: { + name: 'googleSearch' + } } - } - ] + ] + } + return [] } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 8a644f22a1..2bfaadb851 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -284,6 +284,7 @@ "duplicate": "Duplicate", "edit": "Edit", "expand": "Expand", + "collapse": "Collapse", "footnote": "Reference content", "footnotes": "References", "fullscreen": "Entered fullscreen mode. Press F11 to exit", @@ -305,7 +306,12 @@ "topics": "Topics", "warning": "Warning", "you": "You", - "reasoning_content": "Deep reasoning" + "reasoning_content": "Deep reasoning", + "sort": { + "pinyin": "Sort by Pinyin", + "pinyin.asc": "Sort by Pinyin (A-Z)", + "pinyin.desc": "Sort by Pinyin (Z-A)" + } }, "docs": { "title": "Docs" @@ -1034,8 +1040,9 @@ "argsTooltip": "Each argument on a new line", "baseUrlTooltip": "Remote server base URL", "command": "Command", - "sse": "Server-Sent Events(sse)", - "stdio": "Standard Input/Output(stdio)", + "sse": "Server-Sent Events (sse)", + "streamableHttp": "Streamable HTTP (streamableHttp)", + "stdio": "Standard Input/Output (stdio)", "inMemory": "Memory", "config_description": "Configure Model Context Protocol servers", "deleteError": "Failed to delete server", @@ -1113,6 +1120,7 @@ "messages.input.send_shortcuts": "Send shortcuts", "messages.input.show_estimated_tokens": "Show estimated tokens", "messages.input.title": "Input Settings", + "messages.input.enable_quick_triggers": "Enable '/' and '@' triggers", "messages.markdown_rendering_input_message": "Markdown render input message", "messages.math_engine": "Math engine", "messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec", @@ -1298,7 +1306,9 @@ "description": "Tavily is a search engine tailored for AI agents, delivering real-time, accurate results, intelligent query suggestions, and in-depth research capabilities.", "title": "Tavily" }, - "title": "Web Search" + "title": "Web Search", + "overwrite": "Override search service", + "overwrite_tooltip": "Force use search service instead of LLM" }, "quickPhrase": { "title": "Quick Phrases", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 062ec4cca1..a7908d3767 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -277,6 +277,7 @@ "duplicate": "複製", "edit": "編集", "expand": "展開", + "collapse": "折りたたむ", "footnote": "引用内容", "footnotes": "脚注", "fullscreen": "全画面モードに入りました。F11キーで終了します", @@ -298,7 +299,12 @@ "topics": "トピック", "warning": "警告", "you": "あなた", - "reasoning_content": "深く考察済み" + "reasoning_content": "深く考察済み", + "sort": { + "pinyin": "ピンインでソート", + "pinyin.asc": "ピンインで昇順ソート", + "pinyin.desc": "ピンインで降順ソート" + } }, "docs": { "title": "ドキュメント" @@ -1026,8 +1032,9 @@ "argsTooltip": "1行に1つの引数を入力してください", "baseUrlTooltip": "リモートURLアドレス", "command": "コマンド", - "sse": "サーバー送信イベント(sse)", - "stdio": "標準入力/出力(stdio)", + "sse": "サーバー送信イベント (sse)", + "streamableHttp": "ストリーミング可能なHTTP (streamable)", + "stdio": "標準入力/出力 (stdio)", "inMemory": "メモリ", "config_description": "モデルコンテキストプロトコルサーバーの設定", "deleteError": "サーバーの削除に失敗しました", @@ -1105,6 +1112,7 @@ "messages.input.send_shortcuts": "送信ショートカット", "messages.input.show_estimated_tokens": "推定トークン数を表示", "messages.input.title": "入力設定", + "messages.input.enable_quick_triggers": "'/' と '@' を有効にしてクイックメニューを表示します。", "messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング", "messages.math_engine": "数式エンジン", "messages.metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec", @@ -1290,7 +1298,9 @@ "description": "Tavily は、AI エージェントのために特別に開発された検索エンジンで、最新の結果、インテリジェントな検索提案、そして深い研究能力を提供します", "title": "Tavily" }, - "title": "ウェブ検索" + "title": "ウェブ検索", + "overwrite": "サービス検索を上書き", + "overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する" }, "general.auto_check_update.title": "自動更新チェックを有効にする", "quickPhrase": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 9e55694bec..54e7b6a71a 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -277,6 +277,7 @@ "duplicate": "Дублировать", "edit": "Редактировать", "expand": "Развернуть", + "collapse": "Свернуть", "footnote": "Цитируемый контент", "footnotes": "Сноски", "fullscreen": "Вы вошли в полноэкранный режим. Нажмите F11 для выхода", @@ -298,7 +299,12 @@ "topics": "Топики", "warning": "Предупреждение", "you": "Вы", - "reasoning_content": "Глубокий анализ" + "reasoning_content": "Глубокий анализ", + "sort": { + "pinyin": "Сортировать по пиньинь", + "pinyin.asc": "Сортировать по пиньинь (А-Я)", + "pinyin.desc": "Сортировать по пиньинь (Я-А)" + } }, "docs": { "title": "Документация" @@ -1026,8 +1032,9 @@ "argsTooltip": "Каждый аргумент с новой строки", "baseUrlTooltip": "Адрес удаленного URL", "command": "Команда", - "sse": "События, отправляемые сервером(sse)", - "stdio": "Стандартный ввод/вывод(stdio)", + "sse": "События, отправляемые сервером (sse)", + "streamableHttp": "Потоковый HTTP (streamableHttp)", + "stdio": "Стандартный ввод/вывод (stdio)", "inMemory": "Память", "config_description": "Настройка серверов протокола контекста модели", "deleteError": "Не удалось удалить сервер", @@ -1105,6 +1112,7 @@ "messages.input.send_shortcuts": "Горячие клавиши для отправки", "messages.input.show_estimated_tokens": "Показывать затраты токенов", "messages.input.title": "Настройки ввода", + "messages.input.enable_quick_triggers": "Включите '/' и '@', чтобы вызвать быстрое меню.", "messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown", "messages.math_engine": "Математический движок", "messages.metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec", @@ -1290,7 +1298,9 @@ "description": "Tavily — это поисковая система, специально разработанная для ИИ-агентов, предоставляющая актуальные результаты, умные предложения по запросам и глубокие исследовательские возможности", "title": "Tavily" }, - "title": "Поиск в Интернете" + "title": "Поиск в Интернете", + "overwrite": "Переопределить поставщика поиска", + "overwrite_tooltip": "Использовать поставщика поиска вместо LLM" }, "general.auto_check_update.title": "Включить автоматическую проверку обновлений", "quickPhrase": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 2b4ac6567a..58e092d8a3 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -284,6 +284,7 @@ "duplicate": "复制", "edit": "编辑", "expand": "展开", + "collapse": "折叠", "footnote": "引用内容", "footnotes": "引用内容", "fullscreen": "已进入全屏模式,按 F11 退出", @@ -305,7 +306,12 @@ "topics": "话题", "warning": "警告", "you": "用户", - "reasoning_content": "已深度思考" + "reasoning_content": "已深度思考", + "sort": { + "pinyin": "按拼音排序", + "pinyin.asc": "按拼音升序", + "pinyin.desc": "按拼音降序" + } }, "docs": { "title": "帮助文档" @@ -1034,8 +1040,9 @@ "argsTooltip": "每个参数占一行", "baseUrlTooltip": "远程 URL 地址", "command": "命令", - "sse": "服务器发送事件(sse)", - "stdio": "标准输入/输出(stdio)", + "sse": "服务器发送事件 (sse)", + "streamableHttp": "可流式传输的HTTP (streamableHttp)", + "stdio": "标准输入/输出 (stdio)", "inMemory": "内存", "config_description": "配置模型上下文协议服务器", "deleteError": "删除服务器失败", @@ -1113,6 +1120,7 @@ "messages.input.send_shortcuts": "发送快捷键", "messages.input.show_estimated_tokens": "显示预估 Token 数", "messages.input.title": "输入设置", + "messages.input.enable_quick_triggers": "启用 '/' 和 '@' 触发快捷菜单", "messages.markdown_rendering_input_message": "Markdown 渲染输入消息", "messages.math_engine": "数学公式引擎", "messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", @@ -1285,6 +1293,8 @@ "check_success": "验证成功", "enhance_mode": "搜索增强模式", "enhance_mode_tooltip": "使用默认模型提取关键词后搜索", + "overwrite": "覆盖服务商搜索", + "overwrite_tooltip": "强制使用搜索服务商而不是大语言模型进行搜索", "get_api_key": "点击这里获取密钥", "no_provider_selected": "请选择搜索服务商后再检查", "search_max_result": "搜索结果个数", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index a447874cf4..a2b6ed2057 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -277,6 +277,7 @@ "duplicate": "複製", "edit": "編輯", "expand": "展開", + "collapse": "折疊", "footnote": "引用內容", "footnotes": "引用", "fullscreen": "已進入全螢幕模式,按 F11 結束", @@ -298,7 +299,12 @@ "topics": "話題", "warning": "警告", "you": "您", - "reasoning_content": "已深度思考" + "reasoning_content": "已深度思考", + "sort": { + "pinyin": "按拼音排序", + "pinyin.asc": "按拼音升序", + "pinyin.desc": "按拼音降序" + } }, "docs": { "title": "說明文件" @@ -1026,8 +1032,9 @@ "argsTooltip": "每個參數佔一行", "baseUrlTooltip": "遠端 URL 地址", "command": "指令", - "sse": "伺服器傳送事件(sse)", - "stdio": "標準輸入/輸出(stdio)", + "sse": "伺服器傳送事件 (sse)", + "streamableHttp": "可串流的HTTP (streamableHttp)", + "stdio": "標準輸入/輸出 (stdio)", "inMemory": "記憶體", "config_description": "設定模型上下文協議伺服器", "deleteError": "刪除伺服器失敗", @@ -1105,6 +1112,7 @@ "messages.input.send_shortcuts": "傳送快捷鍵", "messages.input.show_estimated_tokens": "顯示預估 Token 數", "messages.input.title": "輸入設定", + "messages.input.enable_quick_triggers": "啟用 '/' 和 '@' 觸發快捷選單", "messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息", "messages.math_engine": "Markdown 渲染輸入訊息", "messages.metrics": "首字延遲 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", @@ -1290,7 +1298,9 @@ "description": "Tavily 是一個為 AI 代理量身訂製的搜尋引擎,提供即時、準確的結果、智慧查詢建議和深入的研究能力", "title": "Tavily" }, - "title": "網路搜尋" + "title": "網路搜尋", + "overwrite": "覆蓋搜尋服務商", + "overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋" }, "general.auto_check_update.title": "啟用自動更新檢查", "quickPhrase": { diff --git a/src/renderer/src/pages/files/FileItem.tsx b/src/renderer/src/pages/files/FileItem.tsx index 5aa0f7c286..ce611ce983 100644 --- a/src/renderer/src/pages/files/FileItem.tsx +++ b/src/renderer/src/pages/files/FileItem.tsx @@ -18,11 +18,13 @@ import styled from 'styled-components' interface FileItemProps { fileInfo: { + icon?: React.ReactNode name: React.ReactNode | string ext: string extra?: React.ReactNode | string actions: React.ReactNode } + style?: React.CSSProperties } const getFileIcon = (type?: string) => { @@ -73,30 +75,31 @@ const getFileIcon = (type?: string) => { return } -const FileItem: React.FC = ({ fileInfo }) => { - const { name, ext, extra, actions } = fileInfo +const FileItem: React.FC = ({ fileInfo, style }) => { + const { name, ext, extra, actions, icon } = fileInfo return ( - + - {getFileIcon(ext)} - + {icon || getFileIcon(ext)} + {name} {extra && {extra}} - {actions} + {actions} ) } const FileItemCard = styled.div` - background: rgba(255, 255, 255, 0.04); border-radius: 8px; overflow: hidden; border: 0.5px solid var(--color-border); flex-shrink: 0; - transition: box-shadow 0.2s ease; + transition: + box-shadow 0.2s ease, + background-color 0.2s ease; --shadow-color: rgba(0, 0, 0, 0.05); &:hover { box-shadow: @@ -109,15 +112,19 @@ const FileItemCard = styled.div` ` const CardContent = styled.div` - padding: 8px 16px; + padding: 8px 8px 8px 16px; display: flex; - align-items: center; + align-items: stretch; gap: 16px; ` const FileIcon = styled.div` + max-height: 44px; color: var(--color-text-3); font-size: 32px; + display: flex; + align-items: center; + justify-content: center; ` const FileName = styled.div` @@ -140,4 +147,11 @@ const FileInfo = styled.div` color: var(--color-text-2); ` +const FileActions = styled.div` + max-height: 44px; + display: flex; + align-items: center; + justify-content: center; +` + export default memo(FileItem) diff --git a/src/renderer/src/pages/files/FileList.tsx b/src/renderer/src/pages/files/FileList.tsx index 41b1ebb100..e00c7cbb92 100644 --- a/src/renderer/src/pages/files/FileList.tsx +++ b/src/renderer/src/pages/files/FileList.tsx @@ -66,7 +66,7 @@ const FileList: React.FC = ({ id, list, files }) => { = ({ id, list, files }) => { {(item) => (
void } -const AttachmentPreview: FC = ({ files, setFiles }) => { - const [visibleId, setVisibleId] = useState('') - +const FileNameRender: FC<{ file: FileType }> = ({ file }) => { + const [visible, setVisible] = useState(false) const isImage = (ext: string) => { return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext) } + return ( + + {isImage(file.ext) && ( + + )} + {formatFileSize(file.size)} + + }> + { + if (isImage(file.ext)) { + setVisible(true) + return + } + const path = FileManager.getSafePath(file) + if (path) { + window.api.file.openPath(path) + } + }}> + {FileManager.formatFileName(file)} + + + ) +} + +const AttachmentPreview: FC = ({ files, setFiles }) => { + const getFileIcon = (type?: string) => { + if (!type) return + + const ext = type.toLowerCase() + + if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) { + return + } + + if (['.doc', '.docx'].includes(ext)) { + return + } + if (['.xls', '.xlsx'].includes(ext)) { + return + } + if (['.ppt', '.pptx'].includes(ext)) { + return + } + if (ext === '.pdf') { + return + } + if (['.md', '.markdown'].includes(ext)) { + return + } + + if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) { + return + } + + if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) { + return + } + + if (['.url'].includes(ext)) { + return + } + + if (['.sitemap'].includes(ext)) { + return + } + + if (['.folder'].includes(ext)) { + return + } + + return + } + if (isEmpty(files)) { return null } return ( - - {files.map((file) => ( - } - bordered={false} - color="cyan" - closable - onClose={() => setFiles(files.filter((f) => f.id !== file.id))}> - { - if (isImage(file.ext)) { - setVisibleId(file.id) - return - } - const path = FileManager.getSafePath(file) - if (path) { - window.api.file.openPath(path) - } - }}> - {FileManager.formatFileName(file)} - {isImage(file.ext) && ( - { - setVisibleId(value ? file.id : '') - } - }} - /> - )} - - - ))} - + {files.map((file) => ( + setFiles(files.filter((f) => f.id !== file.id))}> + + + ))} ) } const ContentContainer = styled.div` width: 100%; + padding: 5px 15px 5px 15px; display: flex; flex-wrap: wrap; - gap: 4px 0; - padding: 5px 15px 0 10px; + gap: 4px 4px; ` const FileName = styled.span` diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 5fb739e92c..79f377e14a 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -15,7 +15,7 @@ import { } from '@ant-design/icons' import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel' import TranslateButton from '@renderer/components/TranslateButton' -import { isFunctionCallingModel, isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models' +import { isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models' import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' @@ -84,7 +84,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = pasteLongTextAsFile, pasteLongTextThreshold, showInputEstimatedTokens, - autoTranslateWithSpace + autoTranslateWithSpace, + enableQuickPanelTriggers } = useSettings() const [expended, setExpend] = useState(false) const [estimateTokenCount, setEstimateTokenCount] = useState(0) @@ -118,7 +119,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const quickPanel = useQuickPanel() const showKnowledgeIcon = useSidebarIconShow('knowledge') - const showMCPToolsIcon = isFunctionCallingModel(model) + // const showMCPToolsIcon = isFunctionCallingModel(model) const [tokenCount, setTokenCount] = useState(0) @@ -198,10 +199,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = userMessage.mentions = mentionModels } - if (isFunctionCallingModel(model)) { - if (!isEmpty(enabledMCPs) && !isEmpty(activedMcpServers)) { - userMessage.enabledMCPs = activedMcpServers.filter((server) => enabledMCPs?.some((s) => s.id === server.id)) - } + if (!isEmpty(enabledMCPs) && !isEmpty(activedMcpServers)) { + userMessage.enabledMCPs = activedMcpServers.filter((server) => enabledMCPs?.some((s) => s.id === server.id)) } userMessage.usage = await estimateMessageUsage(userMessage) @@ -230,7 +229,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = inputEmpty, loading, mentionModels, - model, resizeTextArea, selectedKnowledgeBases, text, @@ -346,17 +344,16 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = description: '', icon: , isMenu: true, - disabled: !showKnowledgeIcon || files.length > 0, + disabled: files.length > 0, action: () => { knowledgeBaseButtonRef.current?.openQuickPanel() } }, { label: t('settings.mcp.title'), - description: showMCPToolsIcon ? '' : t('settings.mcp.not_support'), + description: t('settings.mcp.not_support'), icon: , isMenu: true, - disabled: !showMCPToolsIcon, action: () => { mcpToolsButtonRef.current?.openQuickPanel() } @@ -378,7 +375,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } } ] - }, [files.length, model, openSelectFileMenu, showKnowledgeIcon, showMCPToolsIcon, t, text, translate]) + }, [files.length, model, openSelectFileMenu, t, text, translate]) const handleKeyDown = (event: React.KeyboardEvent) => { const isEnterPressed = event.keyCode == 13 @@ -537,7 +534,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const cursorPosition = textArea?.selectionStart ?? 0 const lastSymbol = newText[cursorPosition - 1] - if (!quickPanel.isVisible && lastSymbol === '/') { + if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') { quickPanel.open({ title: t('settings.quickPanel.title'), list: quickPanelMenu, @@ -545,7 +542,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = }) } - if (!quickPanel.isVisible && lastSymbol === '@') { + if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') { mentionModelsButtonRef.current?.openQuickPanel() } } @@ -777,20 +774,33 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = }) } - const onEnableWebSearch = () => { - if (!isWebSearchModel(model)) { - if (!WebSearchService.isWebSearchEnabled()) { - window.modal.confirm({ - title: t('chat.input.web_search.enable'), - content: t('chat.input.web_search.enable_content'), - centered: true, - okText: t('chat.input.web_search.button.ok'), - onOk: () => { - navigate('/settings/web-search') - } - }) - return + const showWebSearchEnableModal = () => { + window.modal.confirm({ + title: t('chat.input.web_search.enable'), + content: t('chat.input.web_search.enable_content'), + centered: true, + okText: t('chat.input.web_search.button.ok'), + onOk: () => { + navigate('/settings/web-search') } + }) + } + + const shouldShowEnableModal = () => { + // 网络搜索功能是否未启用 + const webSearchNotEnabled = !WebSearchService.isWebSearchEnabled() + // 非网络搜索模型:仅当网络搜索功能未启用时显示启用提示 + if (!isWebSearchModel(model)) { + return webSearchNotEnabled + } + // 网络搜索模型:当允许覆盖但网络搜索功能未启用时显示启用提示 + return WebSearchService.isOverwriteEnabled() && webSearchNotEnabled + } + + const onEnableWebSearch = () => { + if (shouldShowEnableModal()) { + showWebSearchEnableModal() + return } updateAssistant({ ...assistant, enableWebSearch: !assistant.enableWebSearch }) @@ -872,12 +882,16 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = id="inputbar" className={classNames('inputbar-container', inputFocus && 'focus')} ref={containerRef}> - - - + {files.length > 0 && } + {selectedKnowledgeBases.length > 0 && ( + + )} + {mentionModels.length > 0 && ( + + )}