mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 11:20:07 +08:00
Merge branch 'main' into 1600822305-patch-2
# Conflicts: # src/renderer/src/store/settings.ts
This commit is contained in:
commit
e5dbf47b9b
@ -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 小程序
|
||||
可以强制使用搜索引擎覆盖模型自带搜索能力
|
||||
修复部分公式无法正常渲染问题
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
365
src/main/services/MCPStreamableHttpClient.ts
Normal file
365
src/main/services/MCPStreamableHttpClient.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
BIN
src/renderer/src/assets/images/apps/dangbei.jpg
Normal file
BIN
src/renderer/src/assets/images/apps/dangbei.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
67
src/renderer/src/components/CustomTag.tsx
Normal file
67
src/renderer/src/components/CustomTag.tsx
Normal 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;
|
||||
}
|
||||
`
|
||||
@ -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>
|
||||
|
||||
13
src/renderer/src/components/Icons/SVGIcon.tsx
Normal file
13
src/renderer/src/components/Icons/SVGIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
|
||||
131
src/renderer/src/components/ModelTagsWithLabel.tsx
Normal file
131
src/renderer/src/components/ModelTagsWithLabel.tsx
Normal 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
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 []
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": "搜索结果个数",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]')
|
||||
: ''
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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) => ``)
|
||||
.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])
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -66,7 +66,6 @@ const TranslatePage: FC = () => {
|
||||
targetLanguage,
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
console.log('🌟TEO🌟 ~ saveTranslateHistory ~ history:', history)
|
||||
await db.translate_history.add(history)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -52,6 +52,16 @@ class WebSearchService {
|
||||
return enhanceMode
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否启用覆盖搜索
|
||||
* @public
|
||||
* @returns 如果启用覆盖搜索则返回true,否则返回false
|
||||
*/
|
||||
public isOverwriteEnabled(): boolean {
|
||||
const { overwrite } = this.getWebSearchState()
|
||||
return overwrite
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前默认的网络搜索提供商
|
||||
* @public
|
||||
|
||||
@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 91,
|
||||
version: 94,
|
||||
blacklist: ['runtime', 'messages'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(' ')}`)
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
158
src/renderer/src/utils/prompt.ts
Normal file
158
src/renderer/src/utils/prompt.ts
Normal 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
|
||||
}
|
||||
71
yarn.lock
71
yarn.lock
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user