Merge branch 'main' into 1600822305-patch-2

# Conflicts:
#	src/renderer/src/store/settings.ts
This commit is contained in:
kangfenmao 2025-04-09 18:01:54 +08:00
commit e5dbf47b9b
72 changed files with 2508 additions and 1403 deletions

View File

@ -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 小程序
可以强制使用搜索引擎覆盖模型自带搜索能力
修复部分公式无法正常渲染问题

View File

@ -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",

View File

@ -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<ExtractChunkData[]>
/**
* 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,

View File

@ -10,16 +10,7 @@ export default class JinaReranker extends BaseReranker {
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
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}`)
}

View File

@ -10,16 +10,7 @@ export default class SiliconFlowReranker extends BaseReranker {
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
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)

View File

@ -10,15 +10,7 @@ export default class VoyageReranker extends BaseReranker {
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
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)

View File

@ -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<string, Client> = 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

View File

@ -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<string, ReadableStreamDefaultReader<Uint8Array>> = 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<void> {
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<HeadersInit> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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<Uint8Array> | 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()
}
}

View File

@ -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()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -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;

View File

@ -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<CustomCollapseProps> = ({ label, extra, children }) => {
const CustomCollapse: FC<CustomCollapseProps> = ({
label,
extra,
children,
destroyInactivePanel = false,
defaultActiveKey = ['1'],
activeKey,
collapsible = undefined
}) => {
const CollapseStyle = {
width: '100%',
background: 'transparent',
@ -17,7 +29,10 @@ const CustomCollapse: FC<CustomCollapseProps> = ({ 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<CustomCollapseProps> = ({ label, extra, children }) =>
<Collapse
bordered={false}
style={CollapseStyle}
defaultActiveKey={['1']}
defaultActiveKey={defaultActiveKey}
activeKey={activeKey}
destroyInactivePanel={destroyInactivePanel}
collapsible={collapsible}
items={[
{
styles: CollapseItemStyles,

View File

@ -0,0 +1,67 @@
import { CloseOutlined } from '@ant-design/icons'
import { Tooltip } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
interface CustomTagProps {
icon?: React.ReactNode
children?: React.ReactNode | string
color: string
size?: number
tooltip?: string
closable?: boolean
onClose?: () => void
}
const CustomTag: FC<CustomTagProps> = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => {
return (
<Tooltip title={tooltip} placement="top">
<Tag $color={color} $size={size} $closable={closable}>
{icon && icon} {children}
{closable && <CloseIcon $size={size} $color={color} onClick={onClose} />}
</Tag>
</Tooltip>
)
}
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;
}
`

View File

@ -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<DividerWithTextProps> = ({ text }) => {
const DividerWithText: React.FC<DividerWithTextProps> = ({ text, style }) => {
return (
<DividerContainer>
<DividerContainer style={style}>
<DividerText>{text}</DividerText>
<DividerLine />
</DividerContainer>

View File

@ -0,0 +1,13 @@
import { SVGProps } from 'react'
export const StreamlineGoodHealthAndWellBeing = (props: SVGProps<SVGSVGElement>) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 14 14" {...props}>
{/* Icon from Streamline by Streamline - https://creativecommons.org/licenses/by/4.0/ */}
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="m10.097 12.468l-2.773-2.52c-1.53-1.522.717-4.423 2.773-2.045c2.104-2.33 4.303.57 2.773 2.045z"></path>
<path d="M.621 6.088h1.367l1.823 3.19l4.101-7.747l1.823 3.646"></path>
</g>
</svg>
)
}

View File

@ -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;

View File

@ -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<ModelTagsProps> = ({
model,
showFree = true,
showReasoning = true,
showToolsCalling = true,
size = 12,
showLabel = true,
style
}) => {
const { t } = useTranslation()
const [_showLabel, _setShowLabel] = useState(showLabel)
const containerRef = useRef<HTMLDivElement>(null)
const resizeObserver = useRef<ResizeObserver>(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 (
<Container ref={containerRef} style={style}>
{isVisionModel(model) && (
<CustomTag
size={size}
color="#00b96b"
icon={<EyeOutlined style={{ fontSize: size }} />}
tooltip={t('models.type.vision')}>
{_showLabel ? t('models.type.vision') : ''}
</CustomTag>
)}
{isWebSearchModel(model) && (
<CustomTag
size={size}
color="#1677ff"
icon={<GlobalOutlined style={{ fontSize: size }} />}
tooltip={t('models.type.websearch')}>
{_showLabel ? t('models.type.websearch') : ''}
</CustomTag>
)}
{showReasoning && isReasoningModel(model) && (
<CustomTag
size={size}
color="#6372bd"
icon={<i className="iconfont icon-thinking" />}
tooltip={t('models.type.reasoning')}>
{_showLabel ? t('models.type.reasoning') : ''}
</CustomTag>
)}
{showToolsCalling && isFunctionCallingModel(model) && (
<CustomTag
size={size}
color="#f18737"
icon={<ToolOutlined style={{ fontSize: size }} />}
tooltip={t('models.type.function_calling')}>
{_showLabel ? t('models.type.function_calling') : ''}
</CustomTag>
)}
{isEmbeddingModel(model) && (
<CustomTag size={size} color="#FFA500" icon={t('models.type.embedding')} tooltip={t('models.type.embedding')} />
)}
{showFree && isFreeModel(model) && (
<CustomTag size={size} color="#7cb305" icon={t('models.type.free')} tooltip={t('models.type.free')} />
)}
{isRerankModel(model) && (
<CustomTag size={size} color="#6495ED" icon={t('models.type.rerank')} tooltip={t('models.type.rerank')} />
)}
</Container>
)
}
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

View File

@ -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<MenuProps>['items'][number]
@ -130,7 +130,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
label: (
<ModelItem>
<ModelNameRow>
<span>{m?.name}</span> <ModelTags model={m} />
<span>{m?.name}</span> <ModelTagsWithLabel model={m} size={11} showLabel={false} />
</ModelNameRow>
<PinIcon
onClick={(e) => {
@ -184,7 +184,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
<span>
{m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name}
</span>{' '}
<ModelTags model={m.model} />
<ModelTagsWithLabel model={m.model} size={11} showLabel={false} />
</ModelNameRow>
<PinIcon
onClick={(e) => {
@ -481,6 +481,10 @@ const StyledMenu = styled(Menu)`
}
}
}
.anticon {
min-width: auto;
}
}
`

View File

@ -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

View File

@ -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<Props> = ({ 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<QuickPanelOpenOptions[]>([])
const bodyRef = useRef<HTMLDivElement>(null)
@ -65,7 +74,21 @@ export const QuickPanelView: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ setInputText }) => {
}, [searchText])
// 获取当前输入的搜索词
const isComposing = useRef(false)
useEffect(() => {
if (!ctx.isVisible) return
@ -196,11 +220,25 @@ export const QuickPanelView: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ setInputText }) => {
}, [ctx.isVisible])
return (
<QuickPanelContainer $pageSize={ctx.pageSize} className={ctx.isVisible ? 'visible' : ''}>
<QuickPanelContainer
$pageSize={ctx.pageSize}
$selectedColor={selectedColor}
$selectedColorHover={selectedColorHover}
className={ctx.isVisible ? 'visible' : ''}>
<QuickPanelBody ref={bodyRef} onMouseMove={() => setIsMouseOver(true)}>
<QuickPanelContent ref={contentRef} $pageSize={ctx.pageSize} $isMouseOver={isMouseOver}>
{list.map((item, i) => (
@ -450,9 +500,14 @@ export const QuickPanelView: React.FC<Props> = ({ 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);

View File

@ -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
}
]

View File

@ -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<string, any> {
if (WebSearchService.isWebSearchEnabled() && WebSearchService.isOverwriteEnabled()) {
return {}
}
if (isWebSearchModel(model)) {
if (assistant.enableWebSearch) {
const webSearchTools = getWebSearchTools(model)

View File

@ -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 []
}

View File

@ -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",

View File

@ -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": {

View File

@ -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": {

View File

@ -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": "搜索结果个数",

View File

@ -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": {

View File

@ -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 <FileUnknownFilled />
}
const FileItem: React.FC<FileItemProps> = ({ fileInfo }) => {
const { name, ext, extra, actions } = fileInfo
const FileItem: React.FC<FileItemProps> = ({ fileInfo, style }) => {
const { name, ext, extra, actions, icon } = fileInfo
return (
<FileItemCard>
<FileItemCard style={style}>
<CardContent>
<FileIcon>{getFileIcon(ext)}</FileIcon>
<Flex vertical gap={0} flex={1} style={{ width: '0px' }}>
<FileIcon>{icon || getFileIcon(ext)}</FileIcon>
<Flex vertical justify="center" gap={0} flex={1} style={{ width: '0px' }}>
<FileName>{name}</FileName>
{extra && <FileInfo>{extra}</FileInfo>}
</Flex>
{actions}
<FileActions>{actions}</FileActions>
</CardContent>
</FileItemCard>
)
}
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)

View File

@ -66,7 +66,7 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
<VirtualList
data={list}
height={window.innerHeight - 100}
itemHeight={80}
itemHeight={75}
itemKey="key"
style={{ padding: '0 16px 16px 16px' }}
styles={{
@ -80,7 +80,7 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
{(item) => (
<div
style={{
height: '80px',
height: '75px',
paddingTop: '12px'
}}>
<FileItem

View File

@ -223,7 +223,7 @@ const ContentContainer = styled.div`
`
const SideNav = styled.div`
width: var(--assistants-width);
width: var(--settings-width);
border-right: 0.5px solid var(--color-border);
padding: 7px 12px;
user-select: none;

View File

@ -1,7 +1,22 @@
import { FileOutlined } from '@ant-design/icons'
import {
FileExcelFilled,
FileImageFilled,
FileMarkdownFilled,
FilePdfFilled,
FilePptFilled,
FileTextFilled,
FileUnknownFilled,
FileWordFilled,
FileZipFilled,
FolderOpenFilled,
GlobalOutlined,
LinkOutlined
} from '@ant-design/icons'
import CustomTag from '@renderer/components/CustomTag'
import FileManager from '@renderer/services/FileManager'
import { FileType } from '@renderer/types'
import { ConfigProvider, Image, Tag } from 'antd'
import { formatFileSize } from '@renderer/utils'
import { Flex, Image, Tooltip } from 'antd'
import { isEmpty } from 'lodash'
import { FC, useState } from 'react'
import styled from 'styled-components'
@ -11,74 +26,128 @@ interface Props {
setFiles: (files: FileType[]) => void
}
const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
const [visibleId, setVisibleId] = useState('')
const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
const [visible, setVisible] = useState<boolean>(false)
const isImage = (ext: string) => {
return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext)
}
return (
<Tooltip
styles={{
body: {
padding: 5
}
}}
fresh
title={
<Flex vertical gap={2} align="center">
{isImage(file.ext) && (
<Image
style={{ width: 80, maxHeight: 200 }}
src={'file://' + FileManager.getSafePath(file)}
preview={{
visible: visible,
src: 'file://' + FileManager.getSafePath(file),
onVisibleChange: setVisible
}}
/>
)}
{formatFileSize(file.size)}
</Flex>
}>
<FileName
onClick={() => {
if (isImage(file.ext)) {
setVisible(true)
return
}
const path = FileManager.getSafePath(file)
if (path) {
window.api.file.openPath(path)
}
}}>
{FileManager.formatFileName(file)}
</FileName>
</Tooltip>
)
}
const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
const getFileIcon = (type?: string) => {
if (!type) return <FileUnknownFilled />
const ext = type.toLowerCase()
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
return <FileImageFilled />
}
if (['.doc', '.docx'].includes(ext)) {
return <FileWordFilled />
}
if (['.xls', '.xlsx'].includes(ext)) {
return <FileExcelFilled />
}
if (['.ppt', '.pptx'].includes(ext)) {
return <FilePptFilled />
}
if (ext === '.pdf') {
return <FilePdfFilled />
}
if (['.md', '.markdown'].includes(ext)) {
return <FileMarkdownFilled />
}
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
return <FileZipFilled />
}
if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) {
return <FileTextFilled />
}
if (['.url'].includes(ext)) {
return <LinkOutlined />
}
if (['.sitemap'].includes(ext)) {
return <GlobalOutlined />
}
if (['.folder'].includes(ext)) {
return <FolderOpenFilled />
}
return <FileUnknownFilled />
}
if (isEmpty(files)) {
return null
}
return (
<ContentContainer>
<ConfigProvider
theme={{
components: {
Tag: {
borderRadiusSM: 100
}
}
}}>
{files.map((file) => (
<Tag
key={file.id}
icon={<FileOutlined />}
bordered={false}
color="cyan"
closable
onClose={() => setFiles(files.filter((f) => f.id !== file.id))}>
<FileName
onClick={() => {
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) && (
<Image
style={{ display: 'none' }}
src={'file://' + FileManager.getSafePath(file)}
preview={{
visible: visibleId === file.id,
src: 'file://' + FileManager.getSafePath(file),
onVisibleChange: (value) => {
setVisibleId(value ? file.id : '')
}
}}
/>
)}
</FileName>
</Tag>
))}
</ConfigProvider>
{files.map((file) => (
<CustomTag
key={file.id}
icon={getFileIcon(file.ext)}
color="#37a5aa"
closable
onClose={() => setFiles(files.filter((f) => f.id !== file.id))}>
<FileNameRender file={file} />
</CustomTag>
))}
</ContentContainer>
)
}
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`

View File

@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
inputEmpty,
loading,
mentionModels,
model,
resizeTextArea,
selectedKnowledgeBases,
text,
@ -346,17 +344,16 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
description: '',
icon: <FileSearchOutlined />,
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: <CodeOutlined />,
isMenu: true,
disabled: !showMCPToolsIcon,
action: () => {
mcpToolsButtonRef.current?.openQuickPanel()
}
@ -378,7 +375,7 @@ const Inputbar: FC<Props> = ({ 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<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13
@ -537,7 +534,7 @@ const Inputbar: FC<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
})
}
if (!quickPanel.isVisible && lastSymbol === '@') {
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
mentionModelsButtonRef.current?.openQuickPanel()
}
}
@ -777,20 +774,33 @@ const Inputbar: FC<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
id="inputbar"
className={classNames('inputbar-container', inputFocus && 'focus')}
ref={containerRef}>
<AttachmentPreview files={files} setFiles={setFiles} />
<KnowledgeBaseInput
selectedKnowledgeBases={selectedKnowledgeBases}
onRemoveKnowledgeBase={handleRemoveKnowledgeBase}
/>
<MentionModelsInput selectedModels={mentionModels} onRemoveModel={handleRemoveModel} />
{files.length > 0 && <AttachmentPreview files={files} setFiles={setFiles} />}
{selectedKnowledgeBases.length > 0 && (
<KnowledgeBaseInput
selectedKnowledgeBases={selectedKnowledgeBases}
onRemoveKnowledgeBase={handleRemoveKnowledgeBase}
/>
)}
{mentionModels.length > 0 && (
<MentionModelsInput selectedModels={mentionModels} onRemoveModel={handleRemoveModel} />
)}
<Textarea
value={text}
onChange={onChange}
@ -941,14 +955,12 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
disabled={files.length > 0}
/>
)}
{showMCPToolsIcon && (
<MCPToolsButton
ref={mcpToolsButtonRef}
enabledMCPs={enabledMCPs}
toggelEnableMCP={toggelEnableMCP}
ToolbarButton={ToolbarButton}
/>
)}
<MCPToolsButton
ref={mcpToolsButtonRef}
enabledMCPs={enabledMCPs}
toggelEnableMCP={toggelEnableMCP}
ToolbarButton={ToolbarButton}
/>
<GenerateImageButton
model={model}
assistant={assistant}
@ -1042,6 +1054,7 @@ const Container = styled.div`
display: flex;
flex-direction: column;
position: relative;
z-index: 2;
`
const InputBarContainer = styled.div`

View File

@ -1,6 +1,6 @@
import { FileSearchOutlined } from '@ant-design/icons'
import CustomTag from '@renderer/components/CustomTag'
import { KnowledgeBase } from '@renderer/types'
import { ConfigProvider, Flex, Tag } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
@ -9,34 +9,27 @@ const KnowledgeBaseInput: FC<{
onRemoveKnowledgeBase: (knowledgeBase: KnowledgeBase) => void
}> = ({ selectedKnowledgeBases, onRemoveKnowledgeBase }) => {
return (
<Container gap="4px 0" wrap>
<ConfigProvider
theme={{
components: {
Tag: {
borderRadiusSM: 100
}
}
}}>
{selectedKnowledgeBases.map((knowledgeBase) => (
<Tag
icon={<FileSearchOutlined />}
bordered={false}
color="success"
key={knowledgeBase.id}
closable
onClose={() => onRemoveKnowledgeBase(knowledgeBase)}>
{knowledgeBase.name}
</Tag>
))}
</ConfigProvider>
<Container>
{selectedKnowledgeBases.map((knowledgeBase) => (
<CustomTag
icon={<FileSearchOutlined />}
color="#3d9d0f"
key={knowledgeBase.id}
closable
onClose={() => onRemoveKnowledgeBase(knowledgeBase)}>
{knowledgeBase.name}
</CustomTag>
))}
</Container>
)
}
const Container = styled(Flex)`
const Container = styled.div`
width: 100%;
padding: 5px 15px 0 10px;
padding: 5px 15px 5px 15px;
display: flex;
flex-wrap: wrap;
gap: 4px 4px;
`
export default KnowledgeBaseInput

View File

@ -1,5 +1,5 @@
import { PlusOutlined } from '@ant-design/icons'
import ModelTags from '@renderer/components/ModelTags'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import { useQuickPanel } from '@renderer/components/QuickPanel'
import { QuickPanelListItem } from '@renderer/components/QuickPanel/types'
import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models'
@ -51,7 +51,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
.reverse()
.map((item) => ({
label: `${item.provider.isSystem ? t(`provider.${item.provider.id}`) : item.provider.name} | ${item.model.name}`,
description: <ModelTags model={item.model} />,
description: <ModelTagsWithLabel model={item.model} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
icon: (
<Avatar src={getModelLogo(item.model.id)} size={20}>
{first(item.model.name)}

View File

@ -1,7 +1,7 @@
import CustomTag from '@renderer/components/CustomTag'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { ConfigProvider, Flex, Tag } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -19,38 +19,27 @@ const MentionModelsInput: FC<{
}
return (
<Container gap="4px 0" wrap>
<ConfigProvider
theme={{
components: {
Tag: {
borderRadiusSM: 100
}
}
}}>
{selectedModels.map((model) => (
<Tag
icon={<i className="iconfont icon-at" />}
bordered={false}
color="processing"
key={getModelUniqId(model)}
closable
onClose={() => onRemoveModel(model)}>
{model.name} ({getProviderName(model)})
</Tag>
))}
</ConfigProvider>
<Container>
{selectedModels.map((model) => (
<CustomTag
icon={<i className="iconfont icon-at" />}
color="#1677ff"
key={getModelUniqId(model)}
closable
onClose={() => onRemoveModel(model)}>
{model.name} ({getProviderName(model)})
</CustomTag>
))}
</Container>
)
}
const Container = styled(Flex)`
const Container = styled.div`
width: 100%;
padding: 5px 15px 10px;
i.iconfont {
font-size: 12px;
margin-inline-end: 7px;
}
padding: 5px 15px 5px 15px;
display: flex;
flex-wrap: wrap;
gap: 4px 4px;
`
export default MentionModelsInput

View File

@ -86,7 +86,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const searchResults =
message?.metadata?.webSearch?.results ||
message?.metadata?.webSearchInfo ||
message?.metadata?.groundingMetadata?.groundingChunks.map((chunk) => chunk.web) ||
message?.metadata?.groundingMetadata?.groundingChunks?.map((chunk) => chunk?.web) ||
message?.metadata?.annotations?.map((annotation) => annotation.url_citation) ||
[]
const citationsUrls = formattedCitations || []
@ -197,7 +197,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const content = `[@${model.name}](#) ${getBriefInfo(message.content)}`
return <Markdown message={{ ...message, content }} />
}
const toolUseRegex = /<tool_use>([\s\S]*?)<\/tool_use>/g
return (
<Fragment>
<Flex gap="8px" wrap style={{ marginBottom: 10 }}>
@ -205,7 +205,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
</Flex>
<MessageThought message={message} />
<MessageTools message={message} />
<Markdown message={{ ...message, content: processedContent }} />
<Markdown message={{ ...message, content: processedContent.replace(toolUseRegex, '') }} />
{message.metadata?.generateImage && <MessageImage message={message} />}
{message.translatedContent && (
<Fragment>
@ -222,18 +222,22 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
{message?.metadata?.groundingMetadata && message.status == 'success' && (
<>
<CitationsList
citations={message.metadata.groundingMetadata.groundingChunks.map((chunk, index) => ({
number: index + 1,
url: chunk.web?.uri,
title: chunk.web?.title,
showFavicon: false
}))}
citations={
message.metadata.groundingMetadata?.groundingChunks?.map((chunk, index) => ({
number: index + 1,
url: chunk?.web?.uri || '',
title: chunk?.web?.title,
showFavicon: false
})) || []
}
/>
<SearchEntryPoint
dangerouslySetInnerHTML={{
__html: message.metadata.groundingMetadata.searchEntryPoint?.renderedContent
?.replace(/@media \(prefers-color-scheme: light\)/g, 'body[theme-mode="light"]')
.replace(/@media \(prefers-color-scheme: dark\)/g, 'body[theme-mode="dark"]')
__html: message.metadata.groundingMetadata?.searchEntryPoint?.renderedContent
? message.metadata.groundingMetadata.searchEntryPoint.renderedContent
.replace(/@media \(prefers-color-scheme: light\)/g, 'body[theme-mode="light"]')
.replace(/@media \(prefers-color-scheme: dark\)/g, 'body[theme-mode="dark"]')
: ''
}}
/>
</>

View File

@ -36,35 +36,51 @@ const MessageImage: FC<Props> = ({ message }) => {
}
}
// 复制 base64 图片到剪贴板
const onCopy = async (imageBase64: string) => {
// 复制图片到剪贴板
const onCopy = async (type: string, image: string) => {
try {
const base64Data = imageBase64.split(',')[1]
const mimeType = imageBase64.split(';')[0].split(':')[1]
switch (type) {
case 'base64': {
// 处理 base64 格式的图片
const parts = image.split(';base64,')
if (parts.length === 2) {
const mimeType = parts[0].replace('data:', '')
const base64Data = parts[1]
const byteCharacters = atob(base64Data)
const byteArrays: Uint8Array[] = []
const byteCharacters = atob(base64Data)
const byteArrays: Uint8Array[] = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
for (let i = 0; i < byteCharacters.length; i += 512) {
const slice = byteCharacters.slice(i, i + 512)
const byteNumbers = new Array(slice.length)
for (let j = 0; j < slice.length; j++) {
byteNumbers[j] = slice.charCodeAt(j)
const blob = new Blob(byteArrays, { type: mimeType })
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
} else {
throw new Error('无效的 base64 图片格式')
}
break
}
case 'url':
{
// 处理 URL 格式的图片
const response = await fetch(image)
const blob = await response.blob()
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
])
}
break
}
const blob = new Blob(byteArrays, { type: mimeType })
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
])
window.message.success(t('message.copy.success'))
} catch (error) {
console.error('复制图片失败:', error)
@ -95,7 +111,7 @@ const MessageImage: FC<Props> = ({ message }) => {
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
<UndoOutlined onClick={onReset} />
<CopyOutlined onClick={() => onCopy(image)} />
<CopyOutlined onClick={() => onCopy(message.metadata?.generateImage?.type!, image)} />
<DownloadOutlined onClick={() => onDownload(image, index)} />
</ToobarWrapper>
)

View File

@ -73,20 +73,7 @@ const MessageMenubar: FC<Props> = (props) => {
const isUserMessage = message.role === 'user'
const exportMenuOptions = useSelector(
(state: RootState) =>
state.settings.exportMenuOptions || {
image: true,
markdown: true,
markdown_reason: true,
notion: true,
yuque: true,
joplin: true,
obsidian: true,
siyuan: true,
docx: true
}
)
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
// 获取TTS设置
const ttsEnabled = useSelector((state: RootState) => state.settings.ttsEnabled)
@ -131,6 +118,14 @@ const MessageMenubar: FC<Props> = (props) => {
let textToEdit = message.content
// 如果是包含图片的消息,添加图片的 markdown 格式
if (message.metadata?.generateImage?.images) {
const imageMarkdown = message.metadata.generateImage.images
.map((image, index) => `![image-${index}](${image})`)
.join('\n')
textToEdit = `${textToEdit}\n\n${imageMarkdown}`
}
if (message.role === 'assistant' && message.model && isReasoningModel(message.model)) {
const processedMessage = withMessageThought(clone(message))
textToEdit = processedMessage.content
@ -154,8 +149,40 @@ const MessageMenubar: FC<Props> = (props) => {
})
if (editedText && editedText !== textToEdit) {
await editMessage(message.id, { content: editedText })
resendMessage && handleResendUserMessage({ ...message, content: editedText })
// 解析编辑后的文本,提取图片 URL
const imageRegex = /!\[image-\d+\]\((.*?)\)/g
const imageUrls: string[] = []
let match
let content = editedText
while ((match = imageRegex.exec(editedText)) !== null) {
imageUrls.push(match[1])
content = content.replace(match[0], '')
}
// 更新消息内容,保留图片信息
await editMessage(message.id, {
content: content.trim(),
metadata: {
...message.metadata,
generateImage: imageUrls.length > 0 ? {
type: 'url',
images: imageUrls
} : undefined
}
})
resendMessage && handleResendUserMessage({
...message,
content: content.trim(),
metadata: {
...message.metadata,
generateImage: imageUrls.length > 0 ? {
type: 'url',
images: imageUrls
} : undefined
}
})
}
}, [message, editMessage, handleResendUserMessage, t])

View File

@ -313,6 +313,7 @@ const Container = styled(Scrollbar)<ContainerProps>`
padding: 10px 0 20px;
overflow-x: hidden;
background-color: var(--color-background);
z-index: 1;
`
export default Messages

View File

@ -1,7 +1,15 @@
import { DeleteOutlined, EditOutlined, MinusCircleOutlined, SaveOutlined } from '@ant-design/icons'
import {
DeleteOutlined,
EditOutlined,
MinusCircleOutlined,
SaveOutlined,
SortAscendingOutlined,
SortDescendingOutlined
} from '@ant-design/icons'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { modelGenerating } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
@ -16,6 +24,7 @@ import { omit } from 'lodash'
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import * as tinyPinyin from 'tiny-pinyin'
interface AssistantItemProps {
assistant: Assistant
@ -32,6 +41,7 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
const { removeAllTopics } = useAssistant(assistant.id) // 使用当前助手的ID
const { clickAssistantToShowTopic, topicPosition, showAssistantIcon } = useSettings()
const defaultModel = getDefaultModel()
const { assistants, updateAssistants } = useAssistants()
const [isPending, setIsPending] = useState(false)
useEffect(() => {
@ -44,6 +54,24 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
}
}, [isActive, assistant.topics])
const sortByPinyinAsc = useCallback(() => {
const sorted = [...assistants].sort((a, b) => {
const pinyinA = tinyPinyin.convertToPinyin(a.name, '', true)
const pinyinB = tinyPinyin.convertToPinyin(b.name, '', true)
return pinyinA.localeCompare(pinyinB)
})
updateAssistants(sorted)
}, [assistants, updateAssistants])
const sortByPinyinDesc = useCallback(() => {
const sorted = [...assistants].sort((a, b) => {
const pinyinA = tinyPinyin.convertToPinyin(a.name, '', true)
const pinyinB = tinyPinyin.convertToPinyin(b.name, '', true)
return pinyinB.localeCompare(pinyinA)
})
updateAssistants(sorted)
}, [assistants, updateAssistants])
const getMenuItems = useCallback(
(assistant: Assistant): ItemType[] => [
{
@ -92,6 +120,19 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
}
},
{ type: 'divider' },
{
label: t('common.sort.pinyin.asc'),
key: 'sort-asc',
icon: <SortAscendingOutlined />,
onClick: () => sortByPinyinAsc()
},
{
label: t('common.sort.pinyin.desc'),
key: 'sort-desc',
icon: <SortDescendingOutlined />,
onClick: () => sortByPinyinDesc()
},
{ type: 'divider' },
{
label: t('common.delete'),
key: 'delete',
@ -108,7 +149,7 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
}
}
],
[addAgent, addAssistant, onSwitch, removeAllTopics, t, onDelete]
[addAgent, addAssistant, onSwitch, removeAllTopics, t, onDelete, sortByPinyinAsc, sortByPinyinDesc]
)
const handleSwitch = useCallback(async () => {

View File

@ -4,7 +4,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { Assistant } from '@renderer/types'
import { FC, useCallback, useState } from 'react'
import { FC, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -27,6 +27,7 @@ const Assistants: FC<AssistantsTabProps> = ({
const [dragging, setDragging] = useState(false)
const { addAgent } = useAgents()
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const onDelete = useCallback(
(assistant: Assistant) => {
@ -41,7 +42,7 @@ const Assistants: FC<AssistantsTabProps> = ({
)
return (
<Container className="assistants-tab">
<Container className="assistants-tab" ref={containerRef}>
<DragableList
list={assistants}
onUpdate={updateAssistants}
@ -74,7 +75,7 @@ const Assistants: FC<AssistantsTabProps> = ({
)
}
// 样式组件(只定义一次)
// 样式组件
const Container = styled(Scrollbar)`
display: flex;
flex-direction: column;

View File

@ -27,6 +27,7 @@ import {
setCodeShowLineNumbers,
setCodeStyle,
setCodeWrappable,
setEnableQuickPanelTriggers,
setFontSize,
setMathEngine,
setMessageFont,
@ -88,7 +89,8 @@ const SettingsTab: FC<Props> = (props) => {
pasteLongTextThreshold,
multiModelMessageStyle,
thoughtAutoCollapse,
messageNavigation
messageNavigation,
enableQuickPanelTriggers
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
@ -570,6 +572,15 @@ const SettingsTab: FC<Props> = (props) => {
<SettingDivider />
</>
)}
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.enable_quick_triggers')}</SettingRowTitleSmall>
<Switch
size="small"
checked={enableQuickPanelTriggers}
onChange={(checked) => dispatch(setEnableQuickPanelTriggers(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.input.target_language')}</SettingRowTitleSmall>
<StyledSelect

View File

@ -156,20 +156,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
[setActiveTopic]
)
const exportMenuOptions = useSelector(
(state: RootState) =>
state.settings.exportMenuOptions || {
image: true,
markdown: true,
markdown_reason: true,
notion: true,
yuque: true,
joplin: true,
obsidian: true,
siyuan: true,
docx: true
}
)
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
const getTopicMenuItems = useCallback(
(topic: Topic) => {
@ -493,7 +480,6 @@ const TopicListItem = styled.div`
}
.menu {
opacity: 1;
background-color: var(--color-background-soft);
&:hover {
color: var(--color-text-2);
}

View File

@ -1,12 +1,15 @@
import {
ColumnHeightOutlined,
CopyOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
RedoOutlined,
SearchOutlined,
SettingOutlined
SettingOutlined,
VerticalAlignMiddleOutlined
} from '@ant-design/icons'
import CustomTag from '@renderer/components/CustomTag'
import Ellipsis from '@renderer/components/Ellipsis'
import { HStack } from '@renderer/components/Layout'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
@ -21,7 +24,7 @@ import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@sh
import { Alert, Button, Dropdown, Empty, message, Tag, Tooltip, Upload } from 'antd'
import dayjs from 'dayjs'
import VirtualList from 'rc-virtual-list'
import { FC } from 'react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -41,6 +44,7 @@ const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, .
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
const { t } = useTranslation()
const [expandAll, setExpandAll] = useState(false)
const {
base,
@ -229,356 +233,389 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
}
return (
<MainContent>
{!base?.version && (
<Alert message={t('knowledge.not_support')} type="error" style={{ marginBottom: 20 }} showIcon />
)}
{!providerName && (
<Alert message={t('knowledge.no_provider')} type="error" style={{ marginBottom: 20 }} showIcon />
)}
<CustomCollapse
label={<CollapseLabel label={t('files.title')} count={fileItems.length} />}
extra={
<MainContainer>
<HeaderContainer>
<ModelInfo>
<Button
type="text"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
handleAddFile()
}}
disabled={disabled}>
{t('knowledge.add_file')}
</Button>
}>
<Dragger
showUploadList={false}
customRequest={({ file }) => handleDrop([file as File])}
multiple={true}
accept={fileTypes.join(',')}
style={{ marginTop: 10, background: 'transparent' }}>
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
<p className="ant-upload-hint">
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
</p>
</Dragger>
<FlexColumn>
{fileItems.length === 0 ? (
<EmptyView />
) : (
<VirtualList
data={fileItems.reverse()}
height={fileItems.length > 5 ? 400 : fileItems.length * 80}
itemHeight={80}
itemKey="id"
styles={{
verticalScrollBar: {
width: 6
},
verticalScrollBarThumb: {
background: 'var(--color-scrollbar-thumb)'
}
}}>
{(item) => {
const file = item.content as FileType
return (
<div style={{ height: '80px', paddingTop: '12px' }}>
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>
<Ellipsis>
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
</Ellipsis>
</ClickableSpan>
),
ext: file.ext,
extra: `${dayjs(file.created_at).format('MM-DD HH:mm')} · ${formatFileSize(file.size)}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && (
<Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />
)}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="file"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
</div>
)
}}
</VirtualList>
icon={<SettingOutlined />}
onClick={() => KnowledgeSettingsPopup.show({ base })}
size="small"
/>
<div className="model-row">
<div className="label-column">
<label>{t('models.embedding_model')}</label>
</div>
<Tooltip title={providerName} placement="bottom">
<div className="tag-column">
<Tag color="geekblue" style={{ borderRadius: 20, margin: 0 }}>
{base.model.name}
</Tag>
</div>
</Tooltip>
<Tag color="cyan" style={{ borderRadius: 20, margin: 0 }}>
{t('models.dimensions', { dimensions: base.dimensions || 0 })}
</Tag>
</div>
{base.rerankModel && (
<div className="model-row">
<div className="label-column">
<label>{t('models.rerank_model')}</label>
</div>
<Tooltip title={rerankModelProviderName} placement="bottom">
<div className="tag-column">
<Tag color="green" style={{ borderRadius: 20, margin: 0 }}>
{base.rerankModel?.name}
</Tag>
</div>
</Tooltip>
</div>
)}
</FlexColumn>
</CustomCollapse>
<CustomCollapse
label={<CollapseLabel label={t('knowledge.directories')} count={directoryItems.length} />}
extra={
</ModelInfo>
<HStack gap={8} alignItems="center">
<Button
type="text"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
handleAddDirectory()
}}
size="small"
shape="round"
onClick={() => KnowledgeSearchPopup.show({ base })}
icon={<SearchOutlined />}
disabled={disabled}>
{t('knowledge.add_directory')}
{t('knowledge.search')}
</Button>
}>
<FlexColumn>
{directoryItems.length === 0 && <EmptyView />}
{directoryItems.reverse().map((item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
<Ellipsis>
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
</Ellipsis>
</ClickableSpan>
),
ext: '.folder',
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
getProcessingPercent={getProgressingPercentForItem}
type="directory"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
<Tooltip title={expandAll ? t('common.collapse') : t('common.expand')}>
<Button
size="small"
shape="circle"
onClick={() => setExpandAll(!expandAll)}
icon={expandAll ? <VerticalAlignMiddleOutlined /> : <ColumnHeightOutlined />}
disabled={disabled}
/>
))}
</FlexColumn>
</CustomCollapse>
</Tooltip>
</HStack>
</HeaderContainer>
<MainContent>
{!base?.version && (
<Alert message={t('knowledge.not_support')} type="error" style={{ marginBottom: 20 }} showIcon />
)}
{!providerName && (
<Alert message={t('knowledge.no_provider')} type="error" style={{ marginBottom: 20 }} showIcon />
)}
<CustomCollapse
label={<CollapseLabel label={t('files.title')} count={fileItems.length} />}
defaultActiveKey={['1']}
activeKey={expandAll ? ['1'] : undefined}
extra={
<Button
type="text"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
handleAddFile()
}}
disabled={disabled}>
{t('knowledge.add_file')}
</Button>
}>
<Dragger
showUploadList={false}
customRequest={({ file }) => handleDrop([file as File])}
multiple={true}
accept={fileTypes.join(',')}
style={{ marginTop: 10, background: 'transparent' }}>
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
<p className="ant-upload-hint">
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
</p>
</Dragger>
<CustomCollapse
label={<CollapseLabel label={t('knowledge.urls')} count={urlItems.length} />}
extra={
<Button
type="text"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
handleAddUrl()
}}
disabled={disabled}>
{t('knowledge.add_url')}
</Button>
}>
<FlexColumn>
{urlItems.length === 0 && <EmptyView />}
{urlItems.reverse().map((item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<Dropdown
menu={{
items: [
{
key: 'edit',
icon: <EditOutlined />,
label: t('knowledge.edit_remark'),
onClick: () => handleEditRemark(item)
},
{
key: 'copy',
icon: <CopyOutlined />,
label: t('common.copy'),
onClick: () => {
navigator.clipboard.writeText(item.content as string)
message.success(t('message.copied'))
<FlexColumn>
{fileItems.length === 0 ? (
<EmptyView />
) : (
<VirtualList
data={fileItems.reverse()}
height={fileItems.length > 5 ? 400 : fileItems.length * 75}
itemHeight={75}
itemKey="id"
styles={{
verticalScrollBar: {
width: 6
},
verticalScrollBarThumb: {
background: 'var(--color-scrollbar-thumb)'
}
}}>
{(item) => {
const file = item.content as FileType
return (
<div style={{ height: '75px', paddingTop: '12px' }}>
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>
<Ellipsis>
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
</Ellipsis>
</ClickableSpan>
),
ext: file.ext,
extra: `${dayjs(file.created_at).format('MM-DD HH:mm')} · ${formatFileSize(file.size)}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && (
<Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />
)}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="file"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
</div>
)
}}
</VirtualList>
)}
</FlexColumn>
</CustomCollapse>
<CustomCollapse
label={<CollapseLabel label={t('knowledge.directories')} count={directoryItems.length} />}
defaultActiveKey={[]}
activeKey={expandAll ? ['1'] : undefined}
extra={
<Button
type="text"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
handleAddDirectory()
}}
disabled={disabled}>
{t('knowledge.add_directory')}
</Button>
}>
<FlexColumn>
{directoryItems.length === 0 && <EmptyView />}
{directoryItems.reverse().map((item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
<Ellipsis>
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
</Ellipsis>
</ClickableSpan>
),
ext: '.folder',
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
getProcessingPercent={getProgressingPercentForItem}
type="directory"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
</FlexColumn>
</CustomCollapse>
<CustomCollapse
label={<CollapseLabel label={t('knowledge.urls')} count={urlItems.length} />}
defaultActiveKey={[]}
activeKey={expandAll ? ['1'] : undefined}
extra={
<Button
type="text"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
handleAddUrl()
}}
disabled={disabled}>
{t('knowledge.add_url')}
</Button>
}>
<FlexColumn>
{urlItems.length === 0 && <EmptyView />}
{urlItems.reverse().map((item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<Dropdown
menu={{
items: [
{
key: 'edit',
icon: <EditOutlined />,
label: t('knowledge.edit_remark'),
onClick: () => handleEditRemark(item)
},
{
key: 'copy',
icon: <CopyOutlined />,
label: t('common.copy'),
onClick: () => {
navigator.clipboard.writeText(item.content as string)
message.success(t('message.copied'))
}
}
}
]
}}
trigger={['contextMenu']}>
]
}}
trigger={['contextMenu']}>
<ClickableSpan>
<Tooltip title={item.content as string}>
<Ellipsis>
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.remark || (item.content as string)}
</a>
</Ellipsis>
</Tooltip>
</ClickableSpan>
</Dropdown>
),
ext: '.url',
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="url"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
</FlexColumn>
</CustomCollapse>
<CustomCollapse
label={<CollapseLabel label={t('knowledge.sitemaps')} count={sitemapItems.length} />}
defaultActiveKey={[]}
activeKey={expandAll ? ['1'] : undefined}
extra={
<Button
type="text"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
handleAddSitemap()
}}
disabled={disabled}>
{t('knowledge.add_sitemap')}
</Button>
}>
<FlexColumn>
{sitemapItems.length === 0 && <EmptyView />}
{sitemapItems.reverse().map((item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan>
<Tooltip title={item.content as string}>
<Ellipsis>
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.remark || (item.content as string)}
{item.content as string}
</a>
</Ellipsis>
</Tooltip>
</ClickableSpan>
</Dropdown>
),
ext: '.url',
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
),
ext: '.sitemap',
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="sitemap"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
</FlexColumn>
</CustomCollapse>
<CustomCollapse
label={<CollapseLabel label={t('knowledge.notes')} count={noteItems.length} />}
defaultActiveKey={[]}
activeKey={expandAll ? ['1'] : undefined}
extra={
<Button
type="text"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
handleAddNote()
}}
/>
))}
</FlexColumn>
</CustomCollapse>
<CustomCollapse
label={<CollapseLabel label={t('knowledge.sitemaps')} count={sitemapItems.length} />}
extra={
<Button
type="text"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
handleAddSitemap()
}}
disabled={disabled}>
{t('knowledge.add_sitemap')}
</Button>
}>
<FlexColumn>
{sitemapItems.length === 0 && <EmptyView />}
{sitemapItems.reverse().map((item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan>
<Tooltip title={item.content as string}>
<Ellipsis>
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.content as string}
</a>
</Ellipsis>
</Tooltip>
</ClickableSpan>
),
ext: '.sitemap',
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="sitemap"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
</FlexColumn>
</CustomCollapse>
<CustomCollapse
label={<CollapseLabel label={t('knowledge.notes')} count={noteItems.length} />}
extra={
<Button
type="text"
icon={<PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
handleAddNote()
}}
disabled={disabled}>
{t('knowledge.add_note')}
</Button>
}>
<FlexColumn>
{noteItems.length === 0 && <EmptyView />}
{noteItems.reverse().map((note) => (
<FileItem
key={note.id}
fileInfo={{
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
ext: '.txt',
extra: `${dayjs(note.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
<StatusIconWrapper>
<StatusIcon
sourceId={note.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="note"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
</FlexColumn>
</CustomCollapse>
<ModelInfo>
<div className="model-header">
<label>{t('knowledge.model_info')}</label>
<Button icon={<SettingOutlined />} onClick={() => KnowledgeSettingsPopup.show({ base })} size="small" />
</div>
<div className="model-row">
<div className="label-column">
<label>{t('models.embedding_model')}</label>
</div>
<div className="tag-column">
{providerName && <Tag color="purple">{providerName}</Tag>}
<Tag color="blue">{base.model.name}</Tag>
<Tag color="cyan">{t('models.dimensions', { dimensions: base.dimensions || 0 })}</Tag>
</div>
</div>
{base.rerankModel && (
<div className="model-row">
<div className="label-column">
<label>{t('models.rerank_model')}</label>
</div>
<div className="tag-column">
{rerankModelProviderName && <Tag color="purple">{rerankModelProviderName}</Tag>}
<Tag color="blue">{base.rerankModel?.name}</Tag>
</div>
</div>
)}
</ModelInfo>
<IndexSection>
<Button
type="primary"
onClick={() => KnowledgeSearchPopup.show({ base })}
icon={<SearchOutlined />}
disabled={disabled}>
{t('knowledge.search')}
</Button>
</IndexSection>
<BottomSpacer />
</MainContent>
disabled={disabled}>
{t('knowledge.add_note')}
</Button>
}>
<FlexColumn>
{noteItems.length === 0 && <EmptyView />}
{noteItems.reverse().map((note) => (
<FileItem
key={note.id}
fileInfo={{
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
ext: '.txt',
extra: `${dayjs(note.created_at).format('MM-DD HH:mm')}`,
actions: (
<FlexAlignCenter>
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
<StatusIconWrapper>
<StatusIcon
sourceId={note.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="note"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
</FlexColumn>
</CustomCollapse>
</MainContent>
</MainContainer>
)
}
@ -587,42 +624,52 @@ const EmptyView = () => <Empty style={{ margin: 0 }} styles={{ image: { display:
const CollapseLabel = ({ label, count }: { label: string; count: number }) => {
return (
<HStack alignItems="center" gap={10}>
<label>{label}</label>
<Tag style={{ borderRadius: 100, padding: '0 10px' }} color={count ? 'green' : 'default'}>
<label style={{ fontWeight: 600 }}>{label}</label>
<CustomTag size={12} color={count ? '#008001' : '#cccccc'}>
{count}
</Tag>
</CustomTag>
</HStack>
)
}
const MainContent = styled(Scrollbar)`
const MainContainer = styled.div`
display: flex;
width: 100%;
flex-direction: column;
padding-bottom: 50px;
padding: 15px;
position: relative;
gap: 16px;
`
const IndexSection = styled.div`
margin-top: 20px;
const MainContent = styled(Scrollbar)`
padding: 15px 20px;
display: flex;
justify-content: center;
flex-direction: column;
flex: 1;
gap: 20px;
padding-bottom: 50px;
padding-right: 12px;
`
const HeaderContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 16px;
border-bottom: 0.5px solid var(--color-border);
`
const ModelInfo = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
padding: 5px;
color: var(--color-text-3);
flex-direction: row;
align-items: center;
gap: 8px;
height: 50px;
.model-header {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 4px;
}
.model-row {
@ -666,10 +713,6 @@ const ClickableSpan = styled.span`
width: 0;
`
const BottomSpacer = styled.div`
min-height: 20px;
`
const StatusIconWrapper = styled.div`
width: 36px;
height: 36px;

View File

@ -178,7 +178,7 @@ const MainContent = styled(Scrollbar)`
`
const SideNav = styled.div`
width: var(--assistants-width);
min-width: var(--settings-width);
border-right: 0.5px solid var(--color-border);
padding: 12px 10px;
display: flex;

View File

@ -161,7 +161,7 @@ const DataSettings: FC = () => {
<MenuList>
{menuItems.map((item) =>
item.isDivider ? (
<DividerWithText key={item.key} text={item.text || ''} /> // 动态传递分隔符文字
<DividerWithText key={item.key} text={item.text || ''} style={{ margin: '8px 0' }} /> // 动态传递分隔符文字
) : (
<ListItem
key={item.key}

View File

@ -13,20 +13,7 @@ const ExportMenuOptions: FC = () => {
const { theme } = useTheme()
const dispatch = useAppDispatch()
const exportMenuOptions = useSelector(
(state: RootState) =>
state.settings.exportMenuOptions || {
image: true,
markdown: true,
markdown_reason: true,
notion: true,
yuque: true,
joplin: true,
obsidian: true,
siyuan: true,
docx: true
}
)
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
const handleToggleOption = (option: string, checked: boolean) => {
dispatch(

View File

@ -149,7 +149,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
}
// set stdio or sse server
if (values.serverType === 'sse') {
if (values.serverType === 'sse' || server.type === 'streamableHttp') {
mcpServer.baseUrl = values.baseUrl
} else {
mcpServer.command = values.command
@ -358,7 +358,8 @@ const McpSettings: React.FC<Props> = ({ server }) => {
onChange={(e) => setServerType(e.target.value)}
options={[
{ label: t('settings.mcp.stdio'), value: 'stdio' },
{ label: t('settings.mcp.sse'), value: 'sse' }
{ label: t('settings.mcp.sse'), value: 'sse' },
{ label: t('settings.mcp.streamableHttp'), value: 'streamableHttp' }
]}
/>
</Form.Item>
@ -372,6 +373,15 @@ const McpSettings: React.FC<Props> = ({ server }) => {
<Input placeholder="http://localhost:3000/sse" />
</Form.Item>
)}
{serverType === 'streamableHttp' && (
<Form.Item
name="baseUrl"
label={t('settings.mcp.url')}
rules={[{ required: serverType === 'streamableHttp', message: '' }]}
tooltip={t('settings.mcp.baseUrlTooltip')}>
<Input placeholder="http://localhost:3000/mcp" />
</Form.Item>
)}
{serverType === 'stdio' && (
<>
<Form.Item

View File

@ -4,6 +4,7 @@ import { HStack } from '@renderer/components/Layout'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { builtinMCPServers } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { Button, Card, Flex, Input, Space, Spin, Tag, Typography } from 'antd'
import { npxFinder } from 'npx-scope-finder'
import { type FC, useEffect, useState } from 'react'
@ -19,7 +20,7 @@ interface SearchResult {
usage: string
npmLink: string
fullName: string
type: 'stdio' | 'sse' | 'inMemory'
type: MCPServer['type']
}
const npmScopes = ['@cherry', '@modelcontextprotocol', '@gongrzhe', '@mcpmarket']

View File

@ -120,13 +120,11 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve }) => {
tooltip={t('settings.models.add.group_name.tooltip')}>
<Input placeholder={t('settings.models.add.group_name.placeholder')} spellCheck={false} />
</Form.Item>
<Form.Item style={{ marginBottom: 15, textAlign: 'center' }}>
<Flex justify="center" align="center" style={{ position: 'relative' }}>
<div>
<Button type="primary" htmlType="submit" size="middle">
{t('settings.models.add.add_model')}
</Button>
</div>
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Flex justify="end" align="center" style={{ position: 'relative' }}>
<Button type="primary" htmlType="submit" size="middle">
{t('settings.models.add.add_model')}
</Button>
</Flex>
</Form.Item>
</Form>

View File

@ -1,6 +1,7 @@
import { LoadingOutlined, MinusOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
import { Center } from '@renderer/components/Layout'
import ModelTags from '@renderer/components/ModelTags'
import { LoadingOutlined, MinusOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'
import CustomCollapse from '@renderer/components/CustomCollapse'
import CustomTag from '@renderer/components/CustomTag'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import {
getModelLogo,
isEmbeddingModel,
@ -12,11 +13,12 @@ import {
SYSTEM_MODELS
} from '@renderer/config/models'
import { useProvider } from '@renderer/hooks/useProvider'
import FileItem from '@renderer/pages/files/FileItem'
import { fetchModels } from '@renderer/services/ApiService'
import { Model, Provider } from '@renderer/types'
import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils'
import { Avatar, Button, Empty, Flex, Modal, Popover, Radio, Tooltip } from 'antd'
import Search from 'antd/es/input/Search'
import { Avatar, Button, Empty, Flex, Modal, Tabs, Tooltip, Typography } from 'antd'
import Input from 'antd/es/input/Input'
import { groupBy, isEmpty, uniqBy } from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -160,54 +162,66 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
onCancel={onCancel}
afterClose={onClose}
footer={null}
width="680px"
width="800px"
styles={{
content: { padding: 0 },
header: { padding: 22, paddingBottom: 15 }
header: { padding: '16px 22px 30px 22px' }
}}
centered>
<SearchContainer>
<Center>
<Radio.Group
size={i18n.language.startsWith('zh') ? 'middle' : 'small'}
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
buttonStyle="solid">
<Radio.Button value="all">{t('models.all')}</Radio.Button>
<Radio.Button value="reasoning">{t('models.type.reasoning')}</Radio.Button>
<Radio.Button value="vision">{t('models.type.vision')}</Radio.Button>
<Radio.Button value="websearch">{t('models.type.websearch')}</Radio.Button>
<Radio.Button value="free">{t('models.type.free')}</Radio.Button>
<Radio.Button value="embedding">{t('models.type.embedding')}</Radio.Button>
<Radio.Button value="rerank">{t('models.type.rerank')}</Radio.Button>
<Radio.Button value="function_calling">{t('models.type.function_calling')}</Radio.Button>
</Radio.Group>
</Center>
<Search
<Input
prefix={<SearchOutlined />}
size="large"
ref={searchInputRef}
placeholder={t('settings.provider.search_placeholder')}
allowClear
onChange={(e) => setSearchText(e.target.value)}
onSearch={setSearchText}
/>
<Tabs
size={i18n.language.startsWith('zh') ? 'middle' : 'small'}
defaultActiveKey="all"
items={[
{ label: t('models.all'), key: 'all' },
{ label: t('models.type.reasoning'), key: 'reasoning' },
{ label: t('models.type.vision'), key: 'vision' },
{ label: t('models.type.websearch'), key: 'websearch' },
{ label: t('models.type.free'), key: 'free' },
{ label: t('models.type.embedding'), key: 'embedding' },
{ label: t('models.type.rerank'), key: 'rerank' },
{ label: t('models.type.function_calling'), key: 'function_calling' }
]}
onChange={(key) => setFilterType(key)}
/>
</SearchContainer>
<ListContainer>
{Object.keys(modelGroups).map((group) => {
{Object.keys(modelGroups).map((group, i) => {
const isAllInProvider = modelGroups[group].every((model) => isModelInProvider(provider, model.id))
return (
<div key={group}>
<ListHeader key={group}>
{group}
<div>
<CustomCollapse
key={i}
defaultActiveKey={i >= 5 ? [] : ['1']}
label={
<Flex align="center" gap={10}>
<span style={{ fontWeight: 600 }}>{group}</span>
<CustomTag color="#02B96B" size={10}>
{modelGroups[group].length}
</CustomTag>
</Flex>
}
extra={
<Tooltip
destroyTooltipOnHide
title={
isAllInProvider
? t(`settings.models.manage.remove_whole_group`)
: t(`settings.models.manage.add_whole_group`)
}
placement="top">
<Button
type="text"
icon={isAllInProvider ? <MinusOutlined /> : <PlusOutlined />}
title={
isAllInProvider
? t(`settings.models.manage.remove_whole_group`)
: t(`settings.models.manage.add_whole_group`)
}
onClick={() => {
onClick={(e) => {
e.stopPropagation()
if (isAllInProvider) {
modelGroups[group]
.filter((model) => isModelInProvider(provider, model.id))
@ -217,40 +231,67 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
}
}}
/>
</div>
</ListHeader>
{modelGroups[group].map((model) => {
return (
<ListItem key={model.id}>
<ListItemHeader>
<Avatar src={getModelLogo(model.id)} size={24}>
{model?.name?.[0]?.toUpperCase()}
</Avatar>
<ListItemName>
<Tooltip title={model.id} placement="top">
<span style={{ cursor: 'help' }}>{model.name}</span>
</Tooltip>
<ModelTags model={model} />
{!isEmpty(model.description) && (
<Popover
trigger="click"
title={model.name}
content={model.description}
overlayStyle={{ maxWidth: 600 }}>
<Question />
</Popover>
)}
</ListItemName>
</ListItemHeader>
{isModelInProvider(provider, model.id) ? (
<Button type="default" onClick={() => onRemoveModel(model)} icon={<MinusOutlined />} />
) : (
<Button type="primary" onClick={() => onAddModel(model)} icon={<PlusOutlined />} />
)}
</ListItem>
)
})}
</div>
</Tooltip>
}>
<FlexColumn>
{modelGroups[group].map((model) => (
<FileItem
style={{
backgroundColor: isModelInProvider(provider, model.id)
? 'rgba(0, 126, 0, 0.06)'
: 'rgba(255, 255, 255, 0.04)'
}}
key={model.id}
fileInfo={{
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>,
name: (
<ListItemName>
<Tooltip
styles={{
root: {
width: 'auto',
maxWidth: '500px'
}
}}
destroyTooltipOnHide
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
{model.id}
</Typography.Text>
}
placement="top">
<span style={{ cursor: 'help' }}>{model.name}</span>
</Tooltip>
<ModelTagsWithLabel model={model} size={11} />
</ListItemName>
),
extra: (
<div style={{ marginTop: 6 }}>
{model.description && (
<Typography.Paragraph
type="secondary"
ellipsis={{ rows: 1, expandable: true }}
style={{ marginBottom: 0, marginTop: 5 }}>
{model.description}
</Typography.Paragraph>
)}
</div>
),
ext: '.model',
actions: (
<div>
{isModelInProvider(provider, model.id) ? (
<Button type="text" onClick={() => onRemoveModel(model)} icon={<MinusOutlined />} />
) : (
<Button type="text" onClick={() => onAddModel(model)} icon={<PlusOutlined />} />
)}
</div>
)
}}
/>
))}
</FlexColumn>
</CustomCollapse>
)
})}
{isEmpty(list) && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('settings.models.empty')} />}
@ -264,7 +305,6 @@ const SearchContainer = styled.div`
flex-direction: column;
gap: 15px;
padding: 0 22px;
padding-bottom: 10px;
margin-top: -10px;
.ant-radio-group {
@ -274,37 +314,21 @@ const SearchContainer = styled.div`
`
const ListContainer = styled.div`
max-height: 70vh;
height: calc(100vh - 300px);
overflow-y: scroll;
padding-bottom: 20px;
`
const ListHeader = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background-color: var(--color-background-soft);
padding: 8px 22px;
color: var(--color-text);
opacity: 0.4;
`
const ListItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 10px 22px;
`
const ListItemHeader = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 0 6px 16px 6px;
margin-left: 16px;
margin-right: 10px;
height: 22px;
display: flex;
flex-direction: column;
gap: 16px;
`
const FlexColumn = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
`
const ListItemName = styled.div`
@ -314,8 +338,8 @@ const ListItemName = styled.div`
gap: 10px;
color: var(--color-text);
font-size: 14px;
line-height: 1;
font-weight: 600;
margin-left: 6px;
`
const ModelHeaderTitle = styled.div`
@ -325,11 +349,6 @@ const ModelHeaderTitle = styled.div`
margin-right: 10px;
`
const Question = styled(QuestionCircleOutlined)`
cursor: pointer;
color: #888;
`
export default class EditModelsPopup {
static topviewId = 0
static hide() {

View File

@ -160,7 +160,7 @@ const PopupContainer: React.FC<Props> = ({ title, apiKeys, resolve }) => {
/>
</Space>
</Space>
<Button key="start" type="primary" onClick={onStart}>
<Button key="start" type="primary" onClick={onStart} size="small">
{t('settings.models.check.start')}
</Button>
</Space>

View File

@ -1,6 +1,12 @@
import { DownOutlined, UpOutlined } from '@ant-design/icons'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { isEmbeddingModel, isFunctionCallingModel, isReasoningModel, isVisionModel } from '@renderer/config/models'
import {
isEmbeddingModel,
isFunctionCallingModel,
isReasoningModel,
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
import { Model, ModelType } from '@renderer/types'
import { getDefaultGroupName } from '@renderer/utils'
import { Button, Checkbox, Divider, Flex, Form, Input, message, Modal } from 'antd'
@ -102,18 +108,14 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
<Input placeholder={t('settings.models.add.group_name.placeholder')} spellCheck={false} />
</Form.Item>
<Form.Item style={{ marginBottom: 15, textAlign: 'center' }}>
<Flex justify="center" align="center" style={{ position: 'relative' }}>
<div>
<Button type="primary" htmlType="submit" size="middle">
{t('common.save')}
</Button>
</div>
<MoreSettingsRow
onClick={() => setShowModelTypes(!showModelTypes)}
style={{ position: 'absolute', right: 0 }}>
<Flex justify="space-between" align="center" style={{ position: 'relative' }}>
<MoreSettingsRow onClick={() => setShowModelTypes(!showModelTypes)}>
{t('settings.moresetting')}
<ExpandIcon>{showModelTypes ? <UpOutlined /> : <DownOutlined />}</ExpandIcon>
</MoreSettingsRow>
<Button type="primary" htmlType="submit" size="middle">
{t('common.save')}
</Button>
</Flex>
</Form.Item>
{showModelTypes && (
@ -125,7 +127,8 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
...(isVisionModel(model) ? ['vision'] : []),
...(isEmbeddingModel(model) ? ['embedding'] : []),
...(isReasoningModel(model) ? ['reasoning'] : []),
...(isFunctionCallingModel(model) ? ['function_calling'] : [])
...(isFunctionCallingModel(model) ? ['function_calling'] : []),
...(isWebSearchModel(model) ? ['web_search'] : [])
] as ModelType[]
// 合并现有选择和默认类型
@ -165,6 +168,11 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
value: 'vision',
disabled: isVisionModel(model) && !selectedTypes.includes('vision')
},
{
label: t('models.type.websearch'),
value: 'web_search',
disabled: isWebSearchModel(model) && !selectedTypes.includes('web_search')
},
{
label: t('models.type.embedding'),
value: 'embedding',

View File

@ -5,20 +5,23 @@ import {
ExclamationCircleFilled,
LoadingOutlined,
MinusCircleOutlined,
MinusOutlined,
PlusOutlined,
SettingOutlined
} from '@ant-design/icons'
import ModelTags from '@renderer/components/ModelTags'
import CustomCollapse from '@renderer/components/CustomCollapse'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import { getModelLogo } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
import { useProvider } from '@renderer/hooks/useProvider'
import FileItem from '@renderer/pages/files/FileItem'
import { ModelCheckStatus } from '@renderer/services/HealthCheckService'
import { useAppDispatch } from '@renderer/store'
import { setModel } from '@renderer/store/assistants'
import { Model } from '@renderer/types'
import { maskApiKey } from '@renderer/utils/api'
import { Avatar, Button, Card, Flex, Space, Tooltip, Typography } from 'antd'
import { Avatar, Button, Flex, Tooltip, Typography } from 'antd'
import { groupBy, sortBy, toPairs } from 'lodash'
import React, { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -240,71 +243,99 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
return (
<>
{Object.keys(sortedModelGroups).map((group) => (
<Card
key={group}
type="inner"
title={group}
extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')}>
<HoveredRemoveIcon
onClick={() =>
modelGroups[group]
.filter((model) => provider.models.some((m) => m.id === model.id))
.forEach((model) => removeModel(model))
}
/>
</Tooltip>
}
style={{ marginBottom: '10px', border: '0.5px solid var(--color-border)' }}
size="small">
{sortedModelGroups[group].map((model) => {
const modelStatus = modelStatuses.find((status) => status.model.id === model.id)
const isChecking = modelStatus?.checking === true
console.log('model', model.id, getModelLogo(model.id))
<Flex gap={12} vertical>
{Object.keys(sortedModelGroups).map((group, i) => (
<CustomCollapse
defaultActiveKey={i <= 5 ? ['1'] : []}
key={group}
label={
<Flex align="center" gap={10}>
<span style={{ fontWeight: 600 }}>{group}</span>
</Flex>
}
extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')}>
<HoveredRemoveIcon
onClick={() =>
modelGroups[group]
.filter((model) => provider.models.some((m) => m.id === model.id))
.forEach((model) => removeModel(model))
}
/>
</Tooltip>
}>
<Flex gap={10} vertical style={{ marginTop: 10 }}>
{sortedModelGroups[group].map((model) => {
const modelStatus = modelStatuses.find((status) => status.model.id === model.id)
const isChecking = modelStatus?.checking === true
return (
<ModelListItem key={model.id}>
<ModelListHeader>
<Avatar src={getModelLogo(model.id)} size={22} style={{ marginRight: '8px' }}>
{model?.name?.[0]?.toUpperCase()}
</Avatar>
<ModelNameRow>
<span>{model?.name}</span>
<ModelTags model={model} />
</ModelNameRow>
<SettingIcon
onClick={() => !isChecking && onEditModel(model)}
style={{ cursor: isChecking ? 'not-allowed' : 'pointer', opacity: isChecking ? 0.5 : 1 }}
return (
<FileItem
key={model.id}
fileInfo={{
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>,
name: (
<ListItemName>
<Tooltip
styles={{
root: {
width: 'auto',
maxWidth: '500px'
}
}}
destroyTooltipOnHide
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
{model.id}
</Typography.Text>
}
placement="top">
<span>{model.name}</span>
</Tooltip>
<ModelTagsWithLabel model={model} size={11} />
</ListItemName>
),
ext: '.model',
actions: (
<Flex gap={4} align="center">
{renderLatencyText(modelStatus)}
{renderStatusIndicator(modelStatus)}
<Button
type="text"
onClick={() => !isChecking && onEditModel(model)}
disabled={isChecking}
icon={<SettingOutlined />}
/>
<Button
type="text"
onClick={() => !isChecking && removeModel(model)}
disabled={isChecking}
icon={<MinusOutlined />}
/>
</Flex>
)
}}
/>
{renderLatencyText(modelStatus)}
</ModelListHeader>
<Space>
{renderStatusIndicator(modelStatus)}
<RemoveIcon
onClick={() => !isChecking && removeModel(model)}
style={{ cursor: isChecking ? 'not-allowed' : 'pointer', opacity: isChecking ? 0.5 : 1 }}
/>
</Space>
</ModelListItem>
)
})}
</Card>
))}
{docsWebsite && (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.docs_check')} </SettingHelpText>
<SettingHelpLink target="_blank" href={docsWebsite}>
{t(`provider.${provider.id}`) + ' '}
{t('common.docs')}
</SettingHelpLink>
<SettingHelpText>{t('common.and')}</SettingHelpText>
<SettingHelpLink target="_blank" href={modelsWebsite}>
{t('common.models')}
</SettingHelpLink>
<SettingHelpText>{t('settings.provider.docs_more_details')}</SettingHelpText>
</SettingHelpTextRow>
)}
)
})}
</Flex>
</CustomCollapse>
))}
{docsWebsite && (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.docs_check')} </SettingHelpText>
<SettingHelpLink target="_blank" href={docsWebsite}>
{t(`provider.${provider.id}`) + ' '}
{t('common.docs')}
</SettingHelpLink>
<SettingHelpText>{t('common.and')}</SettingHelpText>
<SettingHelpLink target="_blank" href={modelsWebsite}>
{t('common.models')}
</SettingHelpLink>
<SettingHelpText>{t('settings.provider.docs_more_details')}</SettingHelpText>
</SettingHelpTextRow>
)}
</Flex>
<Flex gap={10} style={{ marginTop: '10px' }}>
<Button type="primary" onClick={onManageModel} icon={<EditOutlined />}>
{t('button.manage')}
@ -326,25 +357,24 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
)
}
const ModelListItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 5px 0;
`
const ModelListHeader = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`
const ModelNameRow = styled.div`
const ListItemName = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
color: var(--color-text);
font-size: 14px;
line-height: 1;
font-weight: 600;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: help;
font-family: 'Ubuntu';
line-height: 30px;
font-size: 14px;
}
`
const RemoveIcon = styled(MinusCircleOutlined)`
@ -365,21 +395,11 @@ const HoveredRemoveIcon = styled(RemoveIcon)`
}
`
const SettingIcon = styled(SettingOutlined)`
margin-left: 2px;
color: var(--color-text);
cursor: pointer;
transition: all 0.2s ease-in-out;
&:hover {
color: var(--color-text-2);
}
`
const StatusIndicator = styled.div<{ type: string }>`
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-size: 14px;
cursor: pointer;
color: ${(props) => {
switch (props.type) {
@ -398,6 +418,7 @@ const StatusIndicator = styled.div<{ type: string }>`
const ModelLatencyText = styled(Typography.Text)`
margin-left: 10px;
color: var(--color-text-secondary);
font-size: 12px;
`
export default memo(ModelList)

View File

@ -1,4 +1,5 @@
import { CheckOutlined, ExportOutlined, HeartOutlined, LoadingOutlined, SettingOutlined } from '@ant-design/icons'
import { CheckOutlined, ExportOutlined, LoadingOutlined, SettingOutlined } from '@ant-design/icons'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import { HStack } from '@renderer/components/Layout'
import OAuthButton from '@renderer/components/OAuth/OAuthButton'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
@ -273,7 +274,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
}, [apiKey, provider, updateProvider])
return (
<SettingContainer theme={theme}>
<SettingContainer theme={theme} style={{ background: 'var(--color-background)' }}>
<SettingTitle>
<Flex align="center" gap={8}>
<ProviderName>{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}</ProviderName>
@ -383,7 +384,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<SettingSubtitle style={{ marginBottom: 5 }}>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<Space>
<span>{t('common.models')}</span>
<SettingSubtitle style={{ marginTop: 0 }}>{t('common.models')}</SettingSubtitle>
{!isEmpty(models) && <ModelListSearchBar onSearch={setModelSearchText} />}
</Space>
{!isEmpty(models) && (
@ -391,7 +392,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<Button
type="text"
size="small"
icon={<HeartOutlined />}
icon={<StreamlineGoodHealthAndWellBeing />}
onClick={onHealthCheck}
loading={isHealthChecking}
/>

View File

@ -1,7 +1,7 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setEnhanceMode, setMaxResult, setSearchWithTime } from '@renderer/store/websearch'
import { setEnhanceMode, setMaxResult, setOverwrite, setSearchWithTime } from '@renderer/store/websearch'
import { Slider, Switch, Tooltip } from 'antd'
import { t } from 'i18next'
import { FC } from 'react'
@ -12,6 +12,7 @@ const BasicSettings: FC = () => {
const { theme } = useTheme()
const searchWithTime = useAppSelector((state) => state.websearch.searchWithTime)
const enhanceMode = useAppSelector((state) => state.websearch.enhanceMode)
const overwrite = useAppSelector((state) => state.websearch.overwrite)
const maxResults = useAppSelector((state) => state.websearch.maxResults)
const dispatch = useAppDispatch()
@ -26,6 +27,16 @@ const BasicSettings: FC = () => {
<Switch checked={searchWithTime} onChange={(checked) => dispatch(setSearchWithTime(checked))} />
</SettingRow>
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
<SettingRow>
<SettingRowTitle>
{t('settings.websearch.overwrite')}
<Tooltip title={t('settings.websearch.overwrite_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 5, color: 'var(--color-icon)', cursor: 'pointer' }} />
</Tooltip>
</SettingRowTitle>
<Switch checked={overwrite} onChange={(checked) => dispatch(setOverwrite(checked))} />
</SettingRow>
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
<SettingRow>
<SettingRowTitle>
{t('settings.websearch.enhance_mode')}

View File

@ -66,7 +66,6 @@ const TranslatePage: FC = () => {
targetLanguage,
createdAt: new Date().toISOString()
}
console.log('🌟TEO🌟 ~ saveTranslateHistory ~ history:', history)
await db.translate_history.add(history)
}

View File

@ -1,10 +1,5 @@
import Anthropic from '@anthropic-ai/sdk'
import {
MessageCreateParamsNonStreaming,
MessageParam,
ToolResultBlockParam,
ToolUseBlock
} from '@anthropic-ai/sdk/resources'
import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import { isReasoningModel } from '@renderer/config/models'
import { getStoreSetting } from '@renderer/hooks/useSettings'
@ -17,13 +12,9 @@ import {
} from '@renderer/services/MessagesService'
import { Assistant, FileTypes, MCPToolResponse, Message, Model, Provider, Suggestion } from '@renderer/types'
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import {
anthropicToolUseToMcpTool,
callMCPTool,
mcpToolsToAnthropicTools,
upsertMCPToolResponse
} from '@renderer/utils/mcp-tools'
import { first, flatten, isEmpty, sum, takeRight } from 'lodash'
import { parseAndCallTools } from '@renderer/utils/mcp-tools'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { first, flatten, sum, takeRight } from 'lodash'
import OpenAI from 'openai'
import { CompletionsParams } from '.'
@ -182,16 +173,21 @@ export default class AnthropicProvider extends BaseProvider {
const userMessages = flatten(userMessagesParams)
const lastUserMessage = _messages.findLast((m) => m.role === 'user')
const tools = mcpTools ? mcpToolsToAnthropicTools(mcpTools) : undefined
// const tools = mcpTools ? mcpToolsToAnthropicTools(mcpTools) : undefined
let systemPrompt = assistant.prompt
if (mcpTools && mcpTools.length > 0) {
systemPrompt = buildSystemPrompt(systemPrompt, mcpTools)
}
const body: MessageCreateParamsNonStreaming = {
model: model.id,
messages: userMessages,
tools: isEmpty(tools) ? undefined : tools,
// tools: isEmpty(tools) ? undefined : tools,
max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
system: assistant.prompt,
system: systemPrompt,
// @ts-ignore thinking
thinking: this.getReasoningEffort(assistant, model),
...this.getCustomParameters(assistant)
@ -239,7 +235,6 @@ export default class AnthropicProvider extends BaseProvider {
const processStream = (body: MessageCreateParamsNonStreaming, idx: number) => {
return new Promise<void>((resolve, reject) => {
const toolCalls: ToolUseBlock[] = []
let hasThinkingContent = false
this.sdk.messages
.stream({ ...body, stream: true }, { signal })
@ -292,30 +287,11 @@ export default class AnthropicProvider extends BaseProvider {
}
})
})
.on('contentBlock', (content) => {
if (content.type == 'tool_use') {
toolCalls.push(content)
}
})
.on('finalMessage', async (message) => {
if (toolCalls.length > 0) {
const toolCallResults: ToolResultBlockParam[] = []
for (const toolCall of toolCalls) {
const mcpTool = anthropicToolUseToMcpTool(mcpTools, toolCall)
if (mcpTool) {
upsertMCPToolResponse(toolResponses, { tool: mcpTool, status: 'invoking', id: toolCall.id }, onChunk)
const resp = await callMCPTool(mcpTool)
toolCallResults.push({ type: 'tool_result', tool_use_id: toolCall.id, content: resp.content })
upsertMCPToolResponse(
toolResponses,
{ tool: mcpTool, status: 'done', response: resp, id: toolCall.id },
onChunk
)
}
}
if (toolCallResults.length > 0) {
const content = message.content[0]
if (content && content.type === 'text') {
const toolResults = await parseAndCallTools(content.text, toolResponses, onChunk, idx, mcpTools)
if (toolResults.length > 0) {
userMessages.push({
role: message.role,
content: message.content
@ -323,12 +299,10 @@ export default class AnthropicProvider extends BaseProvider {
userMessages.push({
role: 'user',
content: toolCallResults
content: toolResults.join('\n')
})
const newBody = body
body.messages = userMessages
newBody.messages = userMessages
await processStream(newBody, idx + 1)
}
}

View File

@ -8,8 +8,6 @@ import {
import {
Content,
FileDataPart,
FunctionCallPart,
FunctionResponsePart,
GenerateContentStreamResult,
GoogleGenerativeAI,
HarmBlockThreshold,
@ -18,7 +16,8 @@ import {
Part,
RequestOptions,
SafetySetting,
TextPart
TextPart,
Tool
} from '@google/generative-ai'
import { isGemmaModel, isWebSearchModel } from '@renderer/config/models'
import { getStoreSetting } from '@renderer/hooks/useSettings'
@ -30,14 +29,11 @@ import {
filterEmptyMessages,
filterUserRoleStartMessages
} from '@renderer/services/MessagesService'
import WebSearchService from '@renderer/services/WebSearchService'
import { Assistant, FileType, FileTypes, MCPToolResponse, Message, Model, Provider, Suggestion } from '@renderer/types'
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import {
callMCPTool,
geminiFunctionCallToMcpTool,
mcpToolsToGeminiTools,
upsertMCPToolResponse
} from '@renderer/utils/mcp-tools'
import { parseAndCallTools } from '@renderer/utils/mcp-tools'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { MB } from '@shared/config/constant'
import axios from 'axios'
import { isEmpty, takeRight } from 'lodash'
@ -229,10 +225,17 @@ export default class GeminiProvider extends BaseProvider {
history.push(await this.getMessageContents(message))
}
const tools = mcpToolsToGeminiTools(mcpTools)
let systemInstruction = assistant.prompt
if (mcpTools && mcpTools.length > 0) {
systemInstruction = buildSystemPrompt(assistant.prompt || '', mcpTools)
}
// const tools = mcpToolsToGeminiTools(mcpTools)
const tools: Tool[] = []
const toolResponses: MCPToolResponse[] = []
if (assistant.enableWebSearch && isWebSearchModel(model)) {
if (!WebSearchService.isOverwriteEnabled() && assistant.enableWebSearch && isWebSearchModel(model)) {
tools.push({
// @ts-ignore googleSearch is not a valid tool for Gemini
googleSearch: {}
@ -242,7 +245,7 @@ export default class GeminiProvider extends BaseProvider {
const geminiModel = this.sdk.getGenerativeModel(
{
model: model.id,
...(isGemmaModel(model) ? {} : { systemInstruction: assistant.prompt }),
...(isGemmaModel(model) ? {} : { systemInstruction: systemInstruction }),
safetySettings: this.getSafetySettings(model.id),
tools: tools,
generationConfig: {
@ -267,7 +270,7 @@ export default class GeminiProvider extends BaseProvider {
{
text:
'<start_of_turn>user\n' +
assistant.prompt +
systemInstruction +
'<end_of_turn>\n' +
'<start_of_turn>user\n' +
messageContents.parts[0].text +
@ -306,7 +309,25 @@ export default class GeminiProvider extends BaseProvider {
const userMessagesStream = await chat.sendMessageStream(messageContents.parts, { signal })
let time_first_token_millsec = 0
const processToolUses = async (content: string, idx: number) => {
const toolResults = await parseAndCallTools(content, toolResponses, onChunk, idx, mcpTools)
if (toolResults && toolResults.length > 0) {
history.push(messageContents)
const newChat = geminiModel.startChat({ history })
const newStream = await newChat.sendMessageStream(
[
{
text: toolResults.join('\n')
}
],
{ signal }
)
await processStream(newStream, idx + 1)
}
}
const processStream = async (stream: GenerateContentStreamResult, idx: number) => {
let content = ''
for await (const chunk of stream.stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
@ -316,56 +337,8 @@ export default class GeminiProvider extends BaseProvider {
const time_completion_millsec = new Date().getTime() - start_time_millsec
const functionCalls = chunk.functionCalls()
if (functionCalls) {
const fcallParts: FunctionCallPart[] = []
const fcRespParts: FunctionResponsePart[] = []
for (const call of functionCalls) {
console.log('Function call:', call)
fcallParts.push({ functionCall: call } as FunctionCallPart)
const mcpTool = geminiFunctionCallToMcpTool(mcpTools, call)
if (mcpTool) {
upsertMCPToolResponse(
toolResponses,
{
tool: mcpTool,
status: 'invoking',
id: `${call.name}-${idx}`
},
onChunk
)
const toolCallResponse = await callMCPTool(mcpTool)
fcRespParts.push({
functionResponse: {
name: mcpTool.id,
response: toolCallResponse
}
})
upsertMCPToolResponse(
toolResponses,
{
tool: mcpTool,
status: 'done',
response: toolCallResponse,
id: `${call.name}-${idx}`
},
onChunk
)
}
}
if (fcRespParts) {
history.push(messageContents)
history.push({
role: 'model',
parts: fcallParts
})
const newChat = geminiModel.startChat({ history })
const newStream = await newChat.sendMessageStream(fcRespParts, { signal })
await processStream(newStream, idx + 1)
}
}
content += chunk.text()
processToolUses(content, idx)
onChunk({
text: chunk.text(),

View File

@ -31,21 +31,14 @@ import {
} from '@renderer/types'
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import { addImageFileToContents } from '@renderer/utils/formats'
import {
callMCPTool,
mcpToolsToOpenAITools,
openAIToolsToMcpTool,
upsertMCPToolResponse
} from '@renderer/utils/mcp-tools'
import { parseAndCallTools } from '@renderer/utils/mcp-tools'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { isEmpty, takeRight } from 'lodash'
import OpenAI, { AzureOpenAI } from 'openai'
import {
ChatCompletionAssistantMessageParam,
ChatCompletionContentPart,
ChatCompletionCreateParamsNonStreaming,
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionToolMessageParam
ChatCompletionMessageParam
} from 'openai/resources'
import { CompletionsParams } from '.'
@ -296,55 +289,6 @@ export default class OpenAIProvider extends BaseProvider {
return model.id.startsWith('o1') || model.id.startsWith('o3')
}
/**
* Check if the model is a Glm-4-alltools
* @param model - The model
* @returns True if the model is a Glm-4-alltools, false otherwise
*/
private isZhipuTool(model: Model) {
return model.id.includes('glm-4-alltools')
}
/**
* Clean the tool call arguments
* @param toolCall - The tool call
* @returns The cleaned tool call
*/
private cleanToolCallArgs(toolCall: ChatCompletionMessageToolCall): ChatCompletionMessageToolCall {
if (toolCall.function.arguments) {
let args = toolCall.function.arguments
const codeBlockRegex = /```(?:\w*\n)?([\s\S]*?)```/
const match = args.match(codeBlockRegex)
if (match) {
// Extract content from code block
let extractedArgs = match[1].trim()
// Clean function call format like tool_call(name1=value1,name2=value2)
const functionCallRegex = /^\s*\w+\s*\(([\s\S]*?)\)\s*$/
const functionMatch = extractedArgs.match(functionCallRegex)
if (functionMatch) {
// Try to convert parameters to JSON format
const params = functionMatch[1].split(',').filter(Boolean)
const paramsObj = {}
params.forEach((param) => {
const [name, value] = param.split('=').map((p) => p.trim())
if (name && value !== undefined) {
paramsObj[name] = value
}
})
extractedArgs = JSON.stringify(paramsObj)
}
toolCall.function.arguments = extractedArgs
}
args = toolCall.function.arguments
const firstBraceIndex = args.indexOf('{')
const lastBraceIndex = args.lastIndexOf('}')
if (firstBraceIndex !== -1 && lastBraceIndex !== -1 && firstBraceIndex < lastBraceIndex) {
toolCall.function.arguments = args.substring(firstBraceIndex, lastBraceIndex + 1)
}
}
return toolCall
}
/**
* Generate completions for the assistant
* @param messages - The messages
@ -359,14 +303,16 @@ export default class OpenAIProvider extends BaseProvider {
const model = assistant.model || defaultModel
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
messages = addImageFileToContents(messages)
let systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
let systemMessage = { role: 'system', content: assistant.prompt || '' }
if (isOpenAIoSeries(model)) {
systemMessage = {
role: 'developer',
content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}`
}
}
if (mcpTools && mcpTools.length > 0) {
systemMessage.content = buildSystemPrompt(systemMessage.content || '', mcpTools)
}
const userMessages: ChatCompletionMessageParam[] = []
const _messages = filterUserRoleStartMessages(
@ -429,14 +375,51 @@ export default class OpenAIProvider extends BaseProvider {
const { signal } = abortController
await this.checkIsCopilot()
const tools = mcpTools && mcpTools.length > 0 ? mcpToolsToOpenAITools(mcpTools) : undefined
const reqMessages: ChatCompletionMessageParam[] = [systemMessage, ...userMessages].filter(
Boolean
) as ChatCompletionMessageParam[]
const toolResponses: MCPToolResponse[] = []
let firstChunk = true
const processToolUses = async (content: string, idx: number) => {
const toolResults = await parseAndCallTools(content, toolResponses, onChunk, idx, mcpTools)
if (toolResults.length > 0) {
reqMessages.push({
role: 'assistant',
content: content
} as ChatCompletionMessageParam)
reqMessages.push({
role: 'user',
content: toolResults.join('\n')
} as ChatCompletionMessageParam)
const newStream = await this.sdk.chat.completions
// @ts-ignore key is not typed
.create(
{
model: model.id,
messages: reqMessages,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
max_tokens: maxTokens,
keep_alive: this.keepAliveTime,
stream: isSupportStreamOutput(),
// tools: tools,
...getOpenAIWebSearchParams(assistant, model),
...this.getReasoningEffort(assistant, model),
...this.getProviderSpecificParameters(assistant, model),
...this.getCustomParameters(assistant)
},
{
signal
}
)
await processStream(newStream, idx + 1)
}
}
const processStream = async (stream: any, idx: number) => {
if (!isSupportStreamOutput()) {
const time_completion_millsec = new Date().getTime() - start_time_millsec
@ -450,14 +433,17 @@ export default class OpenAIProvider extends BaseProvider {
}
})
}
const final_tool_calls = {} as Record<number, ChatCompletionMessageToolCall>
let content = ''
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
break
}
const delta = chunk.choices[0]?.delta
if (delta?.content) {
content += delta.content
}
if (delta?.reasoning_content || delta?.reasoning) {
hasReasoningContent = true
@ -479,29 +465,6 @@ export default class OpenAIProvider extends BaseProvider {
const finishReason = chunk.choices[0]?.finish_reason
if (delta?.tool_calls?.length) {
const chunkToolCalls = delta.tool_calls
for (const t of chunkToolCalls) {
const { index, id, function: fn, type } = t
const args = fn && typeof fn.arguments === 'string' ? fn.arguments : ''
if (!(index in final_tool_calls)) {
final_tool_calls[index] = {
id,
function: {
name: fn?.name,
arguments: args
},
type
} as ChatCompletionMessageToolCall
} else {
final_tool_calls[index].function.arguments += args
}
}
if (finishReason !== 'tool_calls') {
continue
}
}
let webSearch: any[] | undefined = undefined
if (assistant.enableWebSearch && isZhipuModel(model) && finishReason === 'stop') {
webSearch = chunk?.web_search
@ -510,102 +473,6 @@ export default class OpenAIProvider extends BaseProvider {
webSearch = chunk?.search_info?.search_results
firstChunk = true
}
if (finishReason === 'tool_calls' || (finishReason === 'stop' && Object.keys(final_tool_calls).length > 0)) {
const toolCalls = Object.values(final_tool_calls).map(this.cleanToolCallArgs)
console.log('start invoke tools', toolCalls)
if (this.isZhipuTool(model)) {
reqMessages.push({
role: 'assistant',
content: `argments=${JSON.stringify(toolCalls[0].function.arguments)}`
})
} else {
reqMessages.push({
role: 'assistant',
tool_calls: toolCalls
} as ChatCompletionAssistantMessageParam)
}
for (const toolCall of toolCalls) {
const mcpTool = openAIToolsToMcpTool(mcpTools, toolCall)
if (!mcpTool) {
continue
}
upsertMCPToolResponse(toolResponses, { tool: mcpTool, status: 'invoking', id: toolCall.id }, onChunk)
const toolCallResponse = await callMCPTool(mcpTool)
const toolResponsContent: { type: string; text?: string; image_url?: { url: string } }[] = []
for (const content of toolCallResponse.content) {
if (content.type === 'text') {
toolResponsContent.push({
type: 'text',
text: content.text
})
} else if (content.type === 'image') {
toolResponsContent.push({
type: 'image_url',
image_url: { url: `data:${content.mimeType};base64,${content.data}` }
})
} else {
console.warn('Unsupported content type:', content.type)
toolResponsContent.push({
type: 'text',
text: 'unsupported content type: ' + content.type
})
}
}
const provider = lastUserMessage?.model?.provider
const modelName = lastUserMessage?.model?.name
if (
modelName?.toLocaleLowerCase().includes('gpt') ||
(provider === 'dashscope' && modelName?.toLocaleLowerCase().includes('qwen'))
) {
reqMessages.push({
role: 'tool',
content: toolResponsContent,
tool_call_id: toolCall.id
} as ChatCompletionToolMessageParam)
} else {
reqMessages.push({
role: 'tool',
content: JSON.stringify(toolResponsContent),
tool_call_id: toolCall.id
} as ChatCompletionToolMessageParam)
}
upsertMCPToolResponse(
toolResponses,
{ tool: mcpTool, status: 'done', response: toolCallResponse, id: toolCall.id },
onChunk
)
}
const newStream = await this.sdk.chat.completions
// @ts-ignore key is not typed
.create(
{
model: model.id,
messages: reqMessages,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
max_tokens: maxTokens,
keep_alive: this.keepAliveTime,
stream: isSupportStreamOutput(),
tools: tools,
...getOpenAIWebSearchParams(assistant, model),
...this.getReasoningEffort(assistant, model),
...this.getProviderSpecificParameters(assistant, model),
...this.getCustomParameters(assistant)
},
{
signal
}
)
await processStream(newStream, idx + 1)
}
onChunk({
text: delta?.content || '',
reasoning_content: delta?.reasoning_content || delta?.reasoning || '',
@ -622,7 +489,10 @@ export default class OpenAIProvider extends BaseProvider {
mcpToolResponse: toolResponses
})
}
await processToolUses(content, idx)
}
const stream = await this.sdk.chat.completions
// @ts-ignore key is not typed
.create(
@ -634,7 +504,7 @@ export default class OpenAIProvider extends BaseProvider {
max_tokens: maxTokens,
keep_alive: this.keepAliveTime,
stream: isSupportStreamOutput(),
tools: tools,
// tools: tools,
...getOpenAIWebSearchParams(assistant, model),
...this.getReasoningEffort(assistant, model),
...this.getProviderSpecificParameters(assistant, model),

View File

@ -59,7 +59,6 @@ export async function fetchChatCompletion({
// Search web
if (WebSearchService.isWebSearchEnabled() && assistant.enableWebSearch && assistant.model) {
const webSearchParams = getOpenAIWebSearchParams(assistant, assistant.model)
if (isEmpty(webSearchParams) && !isOpenAIWebSearch(assistant.model)) {
const lastMessage = findLast(messages, (m) => m.role === 'user')
const lastAnswer = findLast(messages, (m) => m.role === 'assistant')
@ -251,7 +250,7 @@ export async function fetchChatCompletion({
}
}
}
console.log('message', message)
// console.log('message', message)
} catch (error: any) {
if (isAbortError(error)) {
message.status = 'paused'

View File

@ -52,6 +52,16 @@ class WebSearchService {
return enhanceMode
}
/**
*
* @public
* @returns truefalse
*/
public isOverwriteEnabled(): boolean {
const { overwrite } = this.getWebSearchState()
return overwrite
}
/**
*
* @public

View File

@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 91,
version: 94,
blacklist: ['runtime', 'messages'],
migrate
},

View File

@ -13,7 +13,7 @@ import { createMigrate } from 'redux-persist'
import { RootState } from '.'
import { INITIAL_PROVIDERS, moveProvider } from './llm'
import { mcpSlice } from './mcp'
import { DEFAULT_SIDEBAR_ICONS } from './settings'
import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings'
// remove logo base64 data to reduce the size of the state
function removeMiniAppIconsFromState(state: RootState) {
@ -31,6 +31,17 @@ function removeMiniAppFromState(state: RootState, id: string) {
}
}
function addMiniApp(state: RootState, id: string) {
if (state.minapps) {
const app = DEFAULT_MIN_APPS.find((app) => app.id === id)
if (app) {
if (!state.minapps.enabled.find((app) => app.id === id)) {
state.minapps.enabled.push(app)
}
}
}
}
// add provider to state
function addProvider(state: RootState, id: string) {
if (!state.llm.providers.find((p) => p.id === id)) {
@ -737,12 +748,7 @@ const migrateConfig = {
},
'59': (state: RootState) => {
try {
if (state.minapps) {
const flowith = DEFAULT_MIN_APPS.find((app) => app.id === 'flowith')
if (flowith) {
state.minapps.enabled.push(flowith)
}
}
addMiniApp(state, 'flowith')
return state
} catch (error) {
return state
@ -783,12 +789,7 @@ const migrateConfig = {
},
'63': (state: RootState) => {
try {
if (state.minapps) {
const mintop = DEFAULT_MIN_APPS.find((app) => app.id === '3mintop')
if (mintop) {
state.minapps.enabled.push(mintop)
}
}
addMiniApp(state, '3mintop')
return state
} catch (error) {
return state
@ -815,16 +816,9 @@ const migrateConfig = {
try {
addProvider(state, 'gitee-ai')
addProvider(state, 'ppio')
addMiniApp(state, 'aistudio')
state.llm.providers = state.llm.providers.filter((provider) => provider.id !== 'graphrag-kylin-mountain')
if (state.minapps) {
const aistudio = DEFAULT_MIN_APPS.find((app) => app.id === 'aistudio')
if (aistudio) {
state.minapps.enabled.push(aistudio)
}
}
return state
} catch (error) {
return state
@ -832,13 +826,7 @@ const migrateConfig = {
},
'67': (state: RootState) => {
try {
if (state.minapps) {
const xiaoyi = DEFAULT_MIN_APPS.find((app) => app.id === 'xiaoyi')
if (xiaoyi) {
state.minapps.enabled.push(xiaoyi)
}
}
addMiniApp(state, 'xiaoyi')
addProvider(state, 'modelscope')
addProvider(state, 'lmstudio')
addProvider(state, 'perplexity')
@ -856,16 +844,9 @@ const migrateConfig = {
},
'68': (state: RootState) => {
try {
if (state.minapps) {
const notebooklm = DEFAULT_MIN_APPS.find((app) => app.id === 'notebooklm')
if (notebooklm) {
state.minapps.enabled.push(notebooklm)
}
}
addMiniApp(state, 'notebooklm')
addProvider(state, 'modelscope')
addProvider(state, 'lmstudio')
return state
} catch (error) {
return state
@ -873,12 +854,7 @@ const migrateConfig = {
},
'69': (state: RootState) => {
try {
if (state.minapps) {
const coze = DEFAULT_MIN_APPS.find((app) => app.id === 'coze')
if (coze) {
state.minapps.enabled.push(coze)
}
}
addMiniApp(state, 'coze')
state.settings.gridColumns = 2
state.settings.gridPopoverTrigger = 'hover'
return state
@ -923,12 +899,7 @@ const migrateConfig = {
},
'72': (state: RootState) => {
try {
if (state.minapps) {
const monica = DEFAULT_MIN_APPS.find((app) => app.id === 'monica')
if (monica) {
state.minapps.enabled.push(monica)
}
}
addMiniApp(state, 'monica')
// remove duplicate lmstudio providers
const emptyLmStudioProviderIndex = state.llm.providers.findLastIndex(
@ -954,7 +925,7 @@ const migrateConfig = {
addProvider(state, 'lmstudio')
addProvider(state, 'o3')
moveProvider(state.llm.providers, 'o3', 2)
state.llm.providers = moveProvider(state.llm.providers, 'o3', 2)
state.assistants.assistants.forEach((assistant) => {
const leadingEmoji = getLeadingEmoji(assistant.name)
@ -996,14 +967,9 @@ const migrateConfig = {
},
'75': (state: RootState) => {
try {
if (state.minapps) {
const you = DEFAULT_MIN_APPS.find((app) => app.id === 'you')
const cici = DEFAULT_MIN_APPS.find((app) => app.id === 'cici')
const zhihu = DEFAULT_MIN_APPS.find((app) => app.id === 'zhihu')
you && state.minapps.enabled.push(you)
cici && state.minapps.enabled.push(cici)
zhihu && state.minapps.enabled.push(zhihu)
}
addMiniApp(state, 'you')
addMiniApp(state, 'cici')
addMiniApp(state, 'zhihu')
return state
} catch (error) {
return state
@ -1198,6 +1164,34 @@ const migrateConfig = {
} catch (error) {
return state
}
},
'92': (state: RootState) => {
try {
addMiniApp(state, 'dangbei')
state.llm.providers = moveProvider(state.llm.providers, 'qiniu', 12)
return state
} catch (error) {
return state
}
},
'93': (state: RootState) => {
try {
if (!state?.settings?.exportMenuOptions) {
state.settings.exportMenuOptions = settingsInitialState.exportMenuOptions
return state
}
return state
} catch (error) {
return state
}
},
'94': (state: RootState) => {
try {
state.settings.enableQuickPanelTriggers = false
return state
} catch (error) {
return state
}
}
}

View File

@ -123,11 +123,11 @@ export interface SettingsState {
ttsEdgeVoice: string
// TTS过滤选项
ttsFilterOptions: {
filterThinkingProcess: boolean // 过滤思考过程
filterMarkdown: boolean // 过滤Markdown标记
filterCodeBlocks: boolean // 过滤代码块
filterHtmlTags: boolean // 过滤HTML标签
maxTextLength: number // 最大文本长度
filterThinkingProcess: boolean // 过滤思考过程
filterMarkdown: boolean // 过滤Markdown标记
filterCodeBlocks: boolean // 过滤代码块
filterHtmlTags: boolean // 过滤HTML标签
maxTextLength: number // 最大文本长度
}
// Quick Panel Triggers
enableQuickPanelTriggers: boolean
@ -242,11 +242,11 @@ export const initialState: SettingsState = {
// Edge TTS配置
ttsEdgeVoice: 'zh-CN-XiaoxiaoNeural', // 默认使用小小的声音
ttsFilterOptions: {
filterThinkingProcess: true, // 默认过滤思考过程
filterMarkdown: true, // 默认过滤Markdown标记
filterCodeBlocks: true, // 默认过滤代码块
filterHtmlTags: true, // 默认过滤HTML标签
maxTextLength: 4000 // 默认最大文本长度
filterThinkingProcess: true, // 默认过滤思考过程
filterMarkdown: true, // 默认过滤Markdown标记
filterCodeBlocks: true, // 默认过滤代码块
filterHtmlTags: true, // 默认过滤HTML标签
maxTextLength: 4000 // 默认最大文本长度
},
// Quick Panel Triggers
enableQuickPanelTriggers: false,
@ -542,84 +542,87 @@ const settingsSlice = createSlice({
setTtsCustomVoices: (state, action: PayloadAction<string[]>) => {
// 确保所有值都是字符串
state.ttsCustomVoices = action.payload
.filter(voice => voice !== null && voice !== undefined)
.map(voice => typeof voice === 'string' ? voice : String(voice))
.filter((voice) => voice !== null && voice !== undefined)
.map((voice) => (typeof voice === 'string' ? voice : String(voice)))
},
setTtsCustomModels: (state, action: PayloadAction<string[]>) => {
// 确保所有值都是字符串
state.ttsCustomModels = action.payload
.filter(model => model !== null && model !== undefined)
.map(model => typeof model === 'string' ? model : String(model))
.filter((model) => model !== null && model !== undefined)
.map((model) => (typeof model === 'string' ? model : String(model)))
},
resetTtsCustomValues: (state) => {
// 重置所有自定义音色和模型
state.ttsCustomVoices = [];
state.ttsCustomModels = [];
state.ttsCustomVoices = []
state.ttsCustomModels = []
},
addTtsCustomVoice: (state, action: PayloadAction<string>) => {
// 确保添加的是字符串
const voiceStr = typeof action.payload === 'string' ? action.payload : String(action.payload);
const voiceStr = typeof action.payload === 'string' ? action.payload : String(action.payload)
// 检查是否已存在相同的音色
const exists = state.ttsCustomVoices.some(voice => {
const exists = state.ttsCustomVoices.some((voice) => {
if (typeof voice === 'string') {
return voice === voiceStr;
return voice === voiceStr
}
return String(voice) === voiceStr;
});
return String(voice) === voiceStr
})
if (!exists) {
state.ttsCustomVoices.push(voiceStr);
state.ttsCustomVoices.push(voiceStr)
}
},
addTtsCustomModel: (state, action: PayloadAction<string>) => {
// 确保添加的是字符串
const modelStr = typeof action.payload === 'string' ? action.payload : String(action.payload);
const modelStr = typeof action.payload === 'string' ? action.payload : String(action.payload)
// 检查是否已存在相同的模型
const exists = state.ttsCustomModels.some(model => {
const exists = state.ttsCustomModels.some((model) => {
if (typeof model === 'string') {
return model === modelStr;
return model === modelStr
}
return String(model) === modelStr;
});
return String(model) === modelStr
})
if (!exists) {
state.ttsCustomModels.push(modelStr);
state.ttsCustomModels.push(modelStr)
}
},
removeTtsCustomVoice: (state, action: PayloadAction<string>) => {
// 确保删除的是字符串
const voiceStr = typeof action.payload === 'string' ? action.payload : String(action.payload);
const voiceStr = typeof action.payload === 'string' ? action.payload : String(action.payload)
// 过滤掉要删除的音色
state.ttsCustomVoices = state.ttsCustomVoices.filter(voice => {
state.ttsCustomVoices = state.ttsCustomVoices.filter((voice) => {
if (typeof voice === 'string') {
return voice !== voiceStr;
return voice !== voiceStr
}
return String(voice) !== voiceStr;
});
return String(voice) !== voiceStr
})
},
removeTtsCustomModel: (state, action: PayloadAction<string>) => {
// 确保删除的是字符串
const modelStr = typeof action.payload === 'string' ? action.payload : String(action.payload);
const modelStr = typeof action.payload === 'string' ? action.payload : String(action.payload)
// 过滤掉要删除的模型
state.ttsCustomModels = state.ttsCustomModels.filter(model => {
state.ttsCustomModels = state.ttsCustomModels.filter((model) => {
if (typeof model === 'string') {
return model !== modelStr;
return model !== modelStr
}
return String(model) !== modelStr;
});
return String(model) !== modelStr
})
},
// TTS过滤选项的action
setTtsFilterOptions: (state, action: PayloadAction<{
filterThinkingProcess?: boolean
filterMarkdown?: boolean
filterCodeBlocks?: boolean
filterHtmlTags?: boolean
maxTextLength?: number
}>) => {
setTtsFilterOptions: (
state,
action: PayloadAction<{
filterThinkingProcess?: boolean
filterMarkdown?: boolean
filterCodeBlocks?: boolean
filterHtmlTags?: boolean
maxTextLength?: number
}>
) => {
state.ttsFilterOptions = {
...state.ttsFilterOptions,
...action.payload
@ -635,8 +638,6 @@ const settingsSlice = createSlice({
}
})
const settingsActions = settingsSlice.actions
export const {
setShowAssistants,
toggleShowAssistants,
@ -719,8 +720,8 @@ export const {
setMaxKeepAliveMinapps,
setShowOpenedMinappsInSidebar,
setEnableDataCollection,
setEnableQuickPanelTriggers,
setExportMenuOptions,
// TTS相关的action
setTtsEnabled,
setTtsServiceType,
setTtsApiKey,
@ -735,9 +736,7 @@ export const {
addTtsCustomModel,
removeTtsCustomVoice,
removeTtsCustomModel,
setTtsFilterOptions,
// Quick Panel Triggers action
setEnableQuickPanelTriggers
} = settingsActions
setTtsFilterOptions
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@ -14,6 +14,8 @@ export interface WebSearchState {
excludeDomains: string[]
// 是否启用搜索增强模式
enhanceMode: boolean
// 是否覆盖服务商搜索
overwrite: boolean
}
const initialState: WebSearchState = {
@ -38,7 +40,8 @@ const initialState: WebSearchState = {
searchWithTime: true,
maxResults: 5,
excludeDomains: [],
enhanceMode: false
enhanceMode: false,
overwrite: false
}
const websearchSlice = createSlice({
@ -71,6 +74,9 @@ const websearchSlice = createSlice({
},
setEnhanceMode: (state, action: PayloadAction<boolean>) => {
state.enhanceMode = action.payload
},
setOverwrite: (state, action: PayloadAction<boolean>) => {
state.overwrite = action.payload
}
}
})
@ -83,7 +89,8 @@ export const {
setSearchWithTime,
setExcludeDomains,
setMaxResult,
setEnhanceMode
setEnhanceMode,
setOverwrite
} = websearchSlice.actions
export default websearchSlice.reducer

View File

@ -1,3 +1,4 @@
import { GroundingMetadata } from '@google/generative-ai'
import OpenAI from 'openai'
import React from 'react'
import { BuiltinTheme } from 'shiki'
@ -72,7 +73,7 @@ export type Message = {
enabledMCPs?: MCPServer[]
metadata?: {
// Gemini
groundingMetadata?: any
groundingMetadata?: GroundingMetadata
// Perplexity Or Openrouter
citations?: string[]
// OpenAI
@ -135,7 +136,7 @@ export type Provider = {
export type ProviderType = 'openai' | 'anthropic' | 'gemini' | 'qwenlm' | 'azure-openai'
export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'function_calling'
export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'function_calling' | 'web_search'
export type Model = {
id: string
@ -370,7 +371,7 @@ export interface MCPServerParameter {
export interface MCPServer {
id: string
name: string
type?: 'stdio' | 'sse' | 'inMemory'
type?: 'stdio' | 'sse' | 'inMemory' | 'streamableHttp'
description?: string
baseUrl?: string
command?: string

View File

@ -71,12 +71,16 @@ export function withGeminiGrounding(message: Message) {
let content = message.content
groundingSupports.forEach((support) => {
const text = support.segment.text
const indices = support.groundingChunkIndices
const nodes = indices.reduce((acc, index) => {
const text = support?.segment
const indices = support?.groundingChunckIndices
if (!text || !indices) return
const nodes = indices.reduce<string[]>((acc, index) => {
acc.push(`<sup>${index + 1}</sup>`)
return acc
}, [])
content = content.replace(text, `${text} ${nodes.join(' ')}`)
})

View File

@ -21,7 +21,7 @@ import { addMCPServer } from '@renderer/store/mcp'
import { MCPServer, MCPTool, MCPToolResponse } from '@renderer/types'
import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources'
import { ChunkCallbackData } from '../providers/AiProvider'
import { ChunkCallbackData, CompletionsParams } from '../providers/AiProvider'
const ensureValidSchema = (obj: Record<string, any>): FunctionDeclarationSchemaProperty => {
// Filter out unsupported keys for Gemini
@ -375,3 +375,87 @@ export function getMcpServerByTool(tool: MCPTool) {
const servers = store.getState().mcp.servers
return servers.find((s) => s.id === tool.serverId)
}
export function parseToolUse(content: string, mcpTools: MCPTool[]): MCPToolResponse[] {
if (!content || !mcpTools || mcpTools.length === 0) {
return []
}
const toolUsePattern =
/<tool_use>([\s\S]*?)<name>([\s\S]*?)<\/name>([\s\S]*?)<arguments>([\s\S]*?)<\/arguments>([\s\S]*?)<\/tool_use>/g
const tools: MCPToolResponse[] = []
let match
let idx = 0
// Find all tool use blocks
while ((match = toolUsePattern.exec(content)) !== null) {
// const fullMatch = match[0]
const toolName = match[2].trim()
const toolArgs = match[4].trim()
// Try to parse the arguments as JSON
let parsedArgs
try {
parsedArgs = JSON.parse(toolArgs)
} catch (error) {
// If parsing fails, use the string as is
parsedArgs = toolArgs
}
// console.log(`Parsed arguments for tool "${toolName}":`, parsedArgs)
const mcpTool = mcpTools.find((tool) => tool.id === toolName)
if (!mcpTool) {
console.error(`Tool "${toolName}" not found in MCP tools`)
continue
}
// Add to tools array
tools.push({
id: `${toolName}-${idx++}`, // Unique ID for each tool use
tool: {
...mcpTool,
inputSchema: parsedArgs
},
status: 'pending'
})
// Remove the tool use block from the content
// content = content.replace(fullMatch, '')
}
return tools
}
export async function parseAndCallTools(
content: string,
toolResponses: MCPToolResponse[],
onChunk: CompletionsParams['onChunk'],
idx: number,
mcpTools?: MCPTool[]
): Promise<string[]> {
const toolResults: string[] = []
// process tool use
const tools = parseToolUse(content, mcpTools || [])
if (!tools || tools.length === 0) {
return toolResults
}
for (let i = 0; i < tools.length; i++) {
const tool = tools[i]
upsertMCPToolResponse(toolResponses, { id: `${tool.id}-${idx}-${i}`, tool: tool.tool, status: 'invoking' }, onChunk)
}
const toolPromises = tools.map(async (tool, i) => {
const toolCallResponse = await callMCPTool(tool.tool)
const result = `
<tool_use_result>
<name>${tool.id}</name>
<result>${JSON.stringify(toolCallResponse)}</result>
</tool_use_result>
`.trim()
upsertMCPToolResponse(
toolResponses,
{ id: `${tool.id}-${idx}-${i}`, tool: tool.tool, status: 'done', response: toolCallResponse },
onChunk
)
return result
})
toolResults.push(...(await Promise.all(toolPromises)))
return toolResults
}

View File

@ -0,0 +1,158 @@
import { MCPTool } from '@renderer/types'
export const SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \
You can use one tool per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use.
## Tool Use Formatting
Tool use is formatted using XML-style tags. The tool name is enclosed in opening and closing tags, and each parameter is similarly enclosed within its own set of tags. Here's the structure:
<tool_use>
<name>{tool_name}</name>
<arguments>{json_arguments}</arguments>
</tool_use>
The tool name should be the exact name of the tool you are using, and the arguments should be a JSON object containing the parameters required by that tool. For example:
<tool_use>
<name>python_interpreter</name>
<arguments>{"code": "5 + 3 + 1294.678"}</arguments>
</tool_use>
The user will respond with the result of the tool use, which should be formatted as follows:
<tool_use_result>
<name>{tool_name}</name>
<result>{result}</result>
</tool_use_result>
The result should be a string, which can represent a file or any other output type. You can use this result as input for the next action.
For example, if the result of the tool use is an image file, you can use it in the next action like this:
<tool_use>
<name>image_transformer</name>
<arguments>{"image": "image_1.jpg"}</arguments>
</tool_use>
Always adhere to this format for the tool use to ensure proper parsing and execution.
## Tool Use Examples
{{ TOOL_USE_EXAMPLES }}
## Tool Use Available Tools
Above example were using notional tools that might not exist for you. You only have access to these tools:
{{ AVAILABLE_TOOLS }}
## Tool Use Rules
Here are the rules you should always follow to solve your task:
1. Always use the right arguments for the tools. Never use variable names as the action arguments, use the value instead.
2. Call a tool only when needed: do not call the search agent if you do not need information, try to solve the task yourself.
3. If no tool call is needed, just answer the question directly.
4. Never re-do a tool call that you previously did with the exact same parameters.
5. For tool use, MARK SURE use XML tag format as shown in the examples above. Do not use any other format.
# User Instructions
{{ USER_SYSTEM_PROMPT }}
Now Begin! If you solve the task correctly, you will receive a reward of $1,000,000.
`
export const ToolUseExamples = `
Here are a few examples using notional tools:
---
User: Generate an image of the oldest person in this document.
Assistant: I can use the document_qa tool to find out who the oldest person is in the document.
<tool_use>
<name>document_qa</name>
<arguments>{"document": "document.pdf", "question": "Who is the oldest person mentioned?"}</arguments>
</tool_use>
User: <tool_use_result>
<name>document_qa</name>
<result>John Doe, a 55 year old lumberjack living in Newfoundland.</result>
</tool_use_result>
Assistant: I can use the image_generator tool to create a portrait of John Doe.
<tool_use>
<name>image_generator</name>
<arguments>{"prompt": "A portrait of John Doe, a 55-year-old man living in Canada."}</arguments>
</tool_use>
User: <tool_use_result>
<name>image_generator</name>
<result>image.png</result>
</tool_use_result>
Assistant: the image is generated as image.png
---
User: "What is the result of the following operation: 5 + 3 + 1294.678?"
Assistant: I can use the python_interpreter tool to calculate the result of the operation.
<tool_use>
<name>python_interpreter</name>
<arguments>{"code": "5 + 3 + 1294.678"}</arguments>
</tool_use>
User: <tool_use_result>
<name>python_interpreter</name>
<result>1302.678</result>
</tool_use_result>
Assistant: The result of the operation is 1302.678.
---
User: "Which city has the highest population , Guangzhou or Shanghai?"
Assistant: I can use the search tool to find the population of Guangzhou.
<tool_use>
<name>search</name>
<arguments>{"query": "Population Guangzhou"}</arguments>
</tool_use>
User: <tool_use_result>
<name>search</name>
<result>Guangzhou has a population of 15 million inhabitants as of 2021.</result>
</tool_use_result>
Assistant: I can use the search tool to find the population of Shanghai.
<tool_use>
<name>search</name>
<arguments>{"query": "Population Shanghai"}</arguments>
</tool_use>
User: <tool_use_result>
<name>search</name>
<result>26 million (2019)</result>
</tool_use_result>
Assistant: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population.
`
export const AvailableTools = (tools: MCPTool[]) => {
const availableTools = tools
.map((tool) => {
return `
<tool>
<name>${tool.id}</name>
<description>${tool.description}</description>
<arguments>
${tool.inputSchema ? JSON.stringify(tool.inputSchema) : ''}
</arguments>
</tool>
`
})
.join('\n')
return `<tools>
${availableTools}
</tools>`
}
export const buildSystemPrompt = (userSystemPrompt: string, tools: MCPTool[]): string => {
if (tools && tools.length > 0) {
return SYSTEM_PROMPT.replace('{{ USER_SYSTEM_PROMPT }}', userSystemPrompt)
.replace('{{ TOOL_USE_EXAMPLES }}', ToolUseExamples)
.replace('{{ AVAILABLE_TOOLS }}', AvailableTools(tools))
}
return userSystemPrompt
}

View File

@ -2509,9 +2509,9 @@ __metadata:
languageName: node
linkType: hard
"@modelcontextprotocol/sdk@npm:^1.8.0":
version: 1.8.0
resolution: "@modelcontextprotocol/sdk@npm:1.8.0"
"@modelcontextprotocol/sdk@npm:^1.9.0":
version: 1.9.0
resolution: "@modelcontextprotocol/sdk@npm:1.9.0"
dependencies:
content-type: "npm:^1.0.5"
cors: "npm:^2.8.5"
@ -2519,11 +2519,11 @@ __metadata:
eventsource: "npm:^3.0.2"
express: "npm:^5.0.1"
express-rate-limit: "npm:^7.5.0"
pkce-challenge: "npm:^4.1.0"
pkce-challenge: "npm:^5.0.0"
raw-body: "npm:^3.0.0"
zod: "npm:^3.23.8"
zod-to-json-schema: "npm:^3.24.1"
checksum: 10c0/aa453697a9be5e431bc473508654cc77887b35125366c9ec81815d9302872baf708332694c1d5a7ff7d06ac4c22d8446667c24caba78c505f643990b17d95820
checksum: 10c0/d93653990c114690c20db606076afdc1836cdf41e1b0249fb6c3432877caad1577ef2ff9bf9476e259bfaaf422a281cda2b77e9b61eaa9b64b359f3b511b2074
languageName: node
linkType: hard
@ -3910,7 +3910,7 @@ __metadata:
"@hello-pangea/dnd": "npm:^16.6.0"
"@kangfenmao/keyv-storage": "npm:^0.1.0"
"@langchain/community": "npm:^0.3.36"
"@modelcontextprotocol/sdk": "npm:^1.8.0"
"@modelcontextprotocol/sdk": "npm:^1.9.0"
"@notionhq/client": "npm:^2.2.15"
"@reduxjs/toolkit": "npm:^2.2.5"
"@strongtz/win32-arm64-msvc": "npm:^0.4.7"
@ -3937,6 +3937,7 @@ __metadata:
axios: "npm:^1.7.3"
babel-plugin-styled-components: "npm:^2.1.4"
browser-image-compression: "npm:^2.0.2"
color: "npm:^5.0.0"
dayjs: "npm:^1.11.11"
dexie: "npm:^4.0.8"
dexie-react-hooks: "npm:^1.1.7"
@ -4003,6 +4004,7 @@ __metadata:
string-width: "npm:^7.2.0"
styled-components: "npm:^6.1.11"
tar: "npm:^7.4.3"
tiny-pinyin: "npm:^1.3.2"
tinycolor2: "npm:^1.6.0"
tokenx: "npm:^0.4.1"
turndown: "npm:^7.2.0"
@ -5304,6 +5306,15 @@ __metadata:
languageName: node
linkType: hard
"color-convert@npm:^3.0.1":
version: 3.0.1
resolution: "color-convert@npm:3.0.1"
dependencies:
color-name: "npm:^2.0.0"
checksum: 10c0/1ff3db76f4b247aec9062c079b96050f3bcde4fe2183fabf60652b25933fecb85b191bd92044ca60abece39927ad08a3e6d829d9fda9f505c1a1273d13dbc780
languageName: node
linkType: hard
"color-name@npm:1.1.3":
version: 1.1.3
resolution: "color-name@npm:1.1.3"
@ -5311,6 +5322,13 @@ __metadata:
languageName: node
linkType: hard
"color-name@npm:^2.0.0":
version: 2.0.0
resolution: "color-name@npm:2.0.0"
checksum: 10c0/fc0304606e5c5941f4649a9975c03a2ecd52a22aba3dadb3309b3e4ee61d78c3e13ff245e80b9a930955d38c5f32a9004196a7456c4542822aa1fcfea8e928ed
languageName: node
linkType: hard
"color-name@npm:~1.1.4":
version: 1.1.4
resolution: "color-name@npm:1.1.4"
@ -5318,6 +5336,15 @@ __metadata:
languageName: node
linkType: hard
"color-string@npm:^2.0.0":
version: 2.0.1
resolution: "color-string@npm:2.0.1"
dependencies:
color-name: "npm:^2.0.0"
checksum: 10c0/8547edb171cfcc9b56d54664560fba98afd065deedd6812e9545be6448c9c38f89dff51e38d18249b3670fa11647824cbcb77bfbb0c8bff8e37c53c9c0baecc1
languageName: node
linkType: hard
"color-support@npm:^1.1.3":
version: 1.1.3
resolution: "color-support@npm:1.1.3"
@ -5327,6 +5354,16 @@ __metadata:
languageName: node
linkType: hard
"color@npm:^5.0.0":
version: 5.0.0
resolution: "color@npm:5.0.0"
dependencies:
color-convert: "npm:^3.0.1"
color-string: "npm:^2.0.0"
checksum: 10c0/fa5f2e84add2e1622abe016b917cca739535fc9845305db32043a5bde4b8164033f179fd1807ac3fe52c9ee7888f82d80e5ff90d1e2652454a2341ab3d23d086
languageName: node
linkType: hard
"colorette@npm:^2.0.20":
version: 2.0.20
resolution: "colorette@npm:2.0.20"
@ -12763,17 +12800,10 @@ __metadata:
languageName: node
linkType: hard
"pkce-challenge@npm:4.1.0":
version: 4.1.0
resolution: "pkce-challenge@npm:4.1.0"
checksum: 10c0/7cdc45977eb9af6f561a6f48ffcf19bd3e6f0c651727d00feef1c501384b1ed3c32d92ee67636f02011168959aedf099003a7c0bed668e7943444b20558c54e4
languageName: node
linkType: hard
"pkce-challenge@patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch":
version: 4.1.0
resolution: "pkce-challenge@patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch::version=4.1.0&hash=3298c3"
checksum: 10c0/8d5a2ad2d6e826011a95e89081d8b2acc40a9e104dc7c7423b22d81520412c013a72157b7f6259650adf5bf796b97062476b7f4c90a7f6baa606ed124f57c0bc
"pkce-challenge@npm:^5.0.0":
version: 5.0.0
resolution: "pkce-challenge@npm:5.0.0"
checksum: 10c0/c6706d627fdbb6f22bf8cc5d60d96d6b6a7bb481399b336a3d3f4e9bfba3e167a2c32f8ec0b5e74be686a0ba3bcc9894865d4c2dd1b91cea4c05dba1f28602c3
languageName: node
linkType: hard
@ -15564,6 +15594,13 @@ __metadata:
languageName: node
linkType: hard
"tiny-pinyin@npm:^1.3.2":
version: 1.3.2
resolution: "tiny-pinyin@npm:1.3.2"
checksum: 10c0/26ce82ad7ca4ea112ea0c85b5b509b526ab6f61ee695350ec6ddf14cecbceffe812fd22533549406421dc09db1d9b5187b16e51d45ce4aef9c43b2c4941dd7d2
languageName: node
linkType: hard
"tiny-typed-emitter@npm:^2.1.0":
version: 2.1.0
resolution: "tiny-typed-emitter@npm:2.1.0"