Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2

This commit is contained in:
fullex 2025-10-16 17:07:52 +08:00
commit d7eb88f7e2
58 changed files with 6503 additions and 8653 deletions

View File

@ -45,7 +45,7 @@ jobs:
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md
github_token: ${{ secrets.TOKEN_GITHUB_WRITE }}
allowed_non_write_users: "*"
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)"
prompt: |
你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件:
@ -108,3 +108,5 @@ jobs:
使用以下命令获取完整信息:
gh issue view ${{ github.event.issue.number }} --json title,body,comments
env:
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}

View File

@ -128,21 +128,113 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
What's New in v1.6.3
<!--LANG:en-->
What's New in v1.7.0-beta.1
Features:
- Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming
- UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow
- Editor: Add read-only extension support, make TextFilePreview read-only but copyable
- Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models
- Code Tools: Add GitHub Copilot CLI integration
Major Features:
- Agent System: Introducing intelligent Agent capabilities alongside Assistants. Agents can autonomously solve complex problems using Claude Code SDK with tool calling, file operations, and multi-turn reasoning
- Agent Management: Create, configure, and manage agents with custom settings including model selection, tool permissions, accessible paths, and MCP server integrations
- Agent Sessions: Dedicated session management for agent interactions with persistent message history and context tracking
- Unified UI: Streamlined interface combining Assistants and Agents tabs with improved navigation and settings management
Agent Features:
- Tool Support: Web search, file operations, bash commands, and custom MCP tools
- Advanced Configuration: Max turns, temperature, token limits
- Permission Control: Configurable tool approval modes (manual, automatic, none)
- Session Persistence: Automatic message saving with optimized streaming and database integration
- Model Selection: API-based model filtering with provider-specific support
UI/UX Improvements:
- Unified assistant/agent tabs with smooth animations
- In-place session name editing
- Virtual list rendering for improved performance
- Session count indicators for active agents
- Enhanced settings popup with tabbed interface
- Webview keyboard shortcut interception for search functionality
API & Infrastructure:
- RESTful API for agent and session management
- Drizzle ORM integration for agent database
- OAuth support for Claude Code authentication
- Express validator for request validation
- Comprehensive error handling with Zod schemas
Model Updates:
- Gemini 2.5 Image Flash support
- Grok 4 Fast with reasoning capabilities
- Qwen3-omni and Qwen3-vl thinking models
- DeepSeek, Claude 4.5, GLM 4.6 support
- GitHub Copilot CLI integration with gpt-5-codex
Bug Fixes:
- Fix Swagger UI accessibility issues
- Fix AI SDK error display with syntax highlighting
- Fix webview search shortcut handling
- Fix agent model visibility for CherryIn provider
- Fix session message ordering and persistence
- Fix anthropic model visibility in agent configuration
- Fix knowledge base deletion and web search RAG errors
- Fix migration for missing providers
- Fix forked topic retaining old name after rename
- Restore first token latency reporting in metrics
- Fix UI scrollbar and overflow issues
Technical Updates:
- Upgrade to Electron 37.6.0
- Update dependencies across packages
- React 19.2.0 upgrade
- Enhanced Claude Code service with streaming support
- Improved message transformation and streaming lifecycle
- Database migration system with automatic schema sync
- Optimized bundle size and dependency management
<!--LANG:zh-CN-->
v1.7.0-beta.1 新特性
核心功能:
- Agent 系统:引入智能 Agent 能力,与助手(Assistant)并存。Agent 基于 Claude Code SDK 构建,具备工具调用、文件操作和多轮推理能力,可自主解决复杂问题
- Agent 管理:创建、配置和管理 Agent,支持自定义模型选择、工具权限、可访问路径和 MCP 服务器集成
- Agent 会话:专属会话管理系统,支持持久化消息历史和上下文追踪
- 统一界面:精简的助手和 Agent 标签页界面,改进导航和设置管理体验
Agent 功能特性:
- 工具支持网页搜索、文件操作、Bash 命令执行和自定义 MCP 工具
- 高级配置最大轮次、温度、Token 限制
- 权限控制:可配置的工具批准模式(手动、自动、无需批准)
- 会话持久化:自动消息保存,优化的流式传输和数据库集成
- 模型选择:基于 API 的模型过滤,支持特定提供商
界面与交互优化:
- 统一的助手/Agent 标签页,带有流畅动画效果
- 会话名称原地编辑功能
- 虚拟列表渲染,提升性能表现
- 活跃 Agent 的会话计数指示器
- 增强的设置弹窗,采用标签页界面
- Webview 键盘快捷键拦截,支持搜索功能
API 与基础设施:
- RESTful API 用于 Agent 和会话管理
- 集成 Drizzle ORM 管理 Agent 数据库
- Claude Code OAuth 认证支持
- Express validator 请求验证
- 基于 Zod 模式的完善错误处理
模型更新:
- 支持 Gemini 2.5 Image Flash
- Grok 4 Fast 推理能力
- Qwen3-omni 和 Qwen3-vl 思考模型
- DeepSeek、Claude 4.5、GLM 4.6 支持
- GitHub Copilot CLI 集成 gpt-5-codex
问题修复:
- 修复 Swagger UI 无法打开
- 修复 AI SDK 错误显示,添加语法高亮
- 修复 Webview 搜索快捷键处理
- 修复 CherryIn 提供商的 Agent 模型可见性
- 修复会话消息排序和持久化
- 修复 Anthropic 模型在 Agent 配置中的可见性
- 修复知识库删除和网页搜索 RAG 错误
- 修复缺失提供商的迁移问题
技术更新:
- 升级至 React 19.2.0
- 增强 Claude Code 服务流式传输支持
- 改进消息转换和流式生命周期
- 数据库迁移系统,支持自动模式同步
- 优化打包大小和依赖管理
<!--LANG:END-->

View File

@ -86,6 +86,7 @@
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"express": "^5.1.0",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"jsdom": "26.1.0",
@ -95,6 +96,7 @@
"selection-hook": "^1.0.12",
"sharp": "^0.34.3",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"turndown": "7.2.0"
},
@ -265,7 +267,6 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"express": "^5.1.0",
"express-validator": "^7.2.1",
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0",
@ -306,8 +307,8 @@
"pdf-parse": "^1.1.1",
"playwright": "^1.52.0",
"proxy-agent": "^6.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
@ -339,7 +340,6 @@
"string-width": "^7.2.0",
"striptags": "^3.2.0",
"styled-components": "^6.1.11",
"swagger-ui-express": "^5.0.1",
"swr": "^2.3.6",
"tailwindcss": "^4.1.13",
"tar": "^7.4.3",

View File

@ -349,6 +349,7 @@ export enum IpcChannel {
ApiServer_Stop = 'api-server:stop',
ApiServer_Restart = 'api-server:restart',
ApiServer_GetStatus = 'api-server:get-status',
// NOTE: This api is not be used.
ApiServer_GetConfig = 'api-server:get-config',
// Anthropic OAuth

View File

@ -1,3 +1,5 @@
import { isEmpty } from 'lodash'
import type { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels'
import { loggerService } from '../../services/LoggerService'
import { getAvailableProviders, listAllAvailableModels, transformModelToOpenAI } from '../utils'
@ -8,6 +10,10 @@ const logger = loggerService.withContext('ModelsService')
export type ModelsFilter = ApiModelsFilter
const isAnthropicProvider = (provider: { type: string; anthropicApiHost?: string }) => {
return provider.type === 'anthropic' || !isEmpty(provider.anthropicApiHost?.trim())
}
export class ModelsService {
async getModels(filter: ModelsFilter): Promise<ApiModelsResponse> {
try {
@ -16,9 +22,7 @@ export class ModelsService {
let providers = await getAvailableProviders()
if (filter.providerType === 'anthropic') {
providers = providers.filter(
(p) => p.type === 'anthropic' || (p.anthropicApiHost !== undefined && p.anthropicApiHost.trim() !== '')
)
providers = providers.filter(isAnthropicProvider)
}
const models = await listAllAvailableModels(providers)
@ -41,6 +45,10 @@ export class ModelsService {
continue
}
if (filter.supportAnthropic && model.endpoint_type !== 'anthropic' && !isAnthropicProvider(provider)) {
continue
}
const openAIModel = transformModelToOpenAI(model, provider)
const fullModelId = openAIModel.id // This is already in format "provider:model_id"

View File

@ -1,7 +1,7 @@
import { cacheService } from '@data/CacheService'
import { loggerService } from '@logger'
import { reduxService } from '@main/services/ReduxService'
import type { ApiModel, Model, Provider } from '@types'
import type { ApiModel, EndpointType, Model, Provider } from '@types'
const logger = loggerService.withContext('ApiServerUtils')
@ -114,6 +114,7 @@ export async function validateModelId(model: string): Promise<{
error?: ModelValidationError
provider?: Provider
modelId?: string
modelEndpointType?: EndpointType
}> {
try {
if (!model || typeof model !== 'string') {
@ -166,7 +167,8 @@ export async function validateModelId(model: string): Promise<{
}
// Check if model exists in provider
const modelExists = provider.models?.some((m) => m.id === modelId)
const modelInProvider = provider.models?.find((m) => m.id === modelId)
const modelExists = !!modelInProvider
if (!modelExists) {
const availableModels = provider.models?.map((m) => m.id).join(', ') || 'none'
return {
@ -179,10 +181,13 @@ export async function validateModelId(model: string): Promise<{
}
}
const modelEndpointType = modelInProvider?.endpoint_type
return {
valid: true,
provider,
modelId
modelId,
modelEndpointType
}
} catch (error: any) {
logger.error('Error validating model ID', { error, model })

View File

@ -277,7 +277,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
const response = await net.fetch(uploadUrl, {
method: 'PUT',
body: fileBuffer,
body: fileBuffer as unknown as BodyInit,
headers: {
'Content-Type': 'application/pdf'
}

View File

@ -1,5 +1,11 @@
import { IpcChannel } from '@shared/IpcChannel'
import type { ApiServerConfig } from '@types'
import type {
ApiServerConfig,
GetApiServerStatusResult,
RestartApiServerStatusResult,
StartApiServerStatusResult,
StopApiServerStatusResult
} from '@types'
import { ipcMain } from 'electron'
import { apiServer } from '../apiServer'
@ -52,7 +58,7 @@ export class ApiServerService {
registerIpcHandlers(): void {
// API Server
ipcMain.handle(IpcChannel.ApiServer_Start, async () => {
ipcMain.handle(IpcChannel.ApiServer_Start, async (): Promise<StartApiServerStatusResult> => {
try {
await this.start()
return { success: true }
@ -61,7 +67,7 @@ export class ApiServerService {
}
})
ipcMain.handle(IpcChannel.ApiServer_Stop, async () => {
ipcMain.handle(IpcChannel.ApiServer_Stop, async (): Promise<StopApiServerStatusResult> => {
try {
await this.stop()
return { success: true }
@ -70,7 +76,7 @@ export class ApiServerService {
}
})
ipcMain.handle(IpcChannel.ApiServer_Restart, async () => {
ipcMain.handle(IpcChannel.ApiServer_Restart, async (): Promise<RestartApiServerStatusResult> => {
try {
await this.restart()
return { success: true }
@ -79,7 +85,7 @@ export class ApiServerService {
}
})
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async () => {
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async (): Promise<GetApiServerStatusResult> => {
try {
const config = await this.getCurrentConfig()
return {

View File

@ -9,6 +9,7 @@ import { config as apiConfigService } from '@main/apiServer/config'
import { validateModelId } from '@main/apiServer/utils'
import getLoginShellEnvironment from '@main/utils/shell-env'
import { app } from 'electron'
import { isEmpty } from 'lodash'
import type { GetAgentSessionResponse } from '../..'
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
@ -61,11 +62,20 @@ class ClaudeCodeService implements AgentServiceInterface {
})
return aiStream
}
if (
(modelInfo.provider?.type !== 'anthropic' &&
(modelInfo.provider?.anthropicApiHost === undefined || modelInfo.provider.anthropicApiHost.trim() === '')) ||
modelInfo.provider.apiKey === ''
) {
const validateModelInfo: (m: typeof modelInfo) => boolean = (m) => {
const { provider, modelEndpointType } = m
if (!provider) return false
if (isEmpty(provider.apiKey?.trim())) return false
const isAnthropicType = provider.type === 'anthropic'
const isAnthropicEndpoint = modelEndpointType === 'anthropic'
const hasValidApiHost = !isEmpty(provider.anthropicApiHost?.trim())
return !(!isAnthropicType && !isAnthropicEndpoint && !hasValidApiHost)
}
if (!modelInfo.provider || !validateModelInfo(modelInfo)) {
logger.error('Anthropic provider configuration is missing', {
modelInfo
})

View File

@ -19,6 +19,7 @@ import type {
FileListResponse,
FileMetadata,
FileUploadResponse,
GetApiServerStatusResult,
KnowledgeBaseParams,
KnowledgeItem,
KnowledgeSearchResult,
@ -29,8 +30,11 @@ import type {
OcrProvider,
OcrResult,
Provider,
RestartApiServerStatusResult,
S3Config,
Shortcut,
StartApiServerStatusResult,
StopApiServerStatusResult,
SupportedOcrFile,
WebDavConfig
} from '@types'
@ -537,6 +541,12 @@ const api = {
ipcRenderer.on(channel, listener)
return () => ipcRenderer.off(channel, listener)
}
},
apiServer: {
getStatus: (): Promise<GetApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus),
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
}
}

View File

@ -4,10 +4,11 @@
*/
import { loggerService } from '@logger'
import type { AISDKWebSearchResult, MCPTool, WebSearchResults } from '@renderer/types'
import { WebSearchSource } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { ChunkType } from '@renderer/types/chunk'
import type { AISDKWebSearchResult, MCPTool, WebSearchResults } from '@types'
import { WebSearchSource } from '@types'
import { type Chunk, ChunkType } from '@renderer/types/chunk'
import { ProviderSpecificError } from '@renderer/types/provider-specific-error'
import { formatErrorMessage } from '@renderer/utils/error'
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
import type { TextStreamPart, ToolSet } from 'ai'
@ -357,7 +358,11 @@ export class AiSdkToChunkAdapter {
case 'error':
this.onChunk({
type: ChunkType.ERROR,
error: chunk.error as Record<string, any>
error: new ProviderSpecificError({
message: formatErrorMessage(chunk.error),
provider: 'unknown',
cause: chunk.error
})
})
break

View File

@ -50,7 +50,11 @@ export const ImageGenerationMiddleware: CompletionsMiddleware =
if (!block.file) return null
const binaryData: Uint8Array = await FileManager.readBinaryImage(block.file)
const mimeType = `${block.file.type}/${block.file.ext.slice(1)}`
return await toFile(new Blob([binaryData]), block.file.origin_name || 'image.png', { type: mimeType })
return await toFile(
new Blob([binaryData as unknown as BlobPart]),
block.file.origin_name || 'image.png',
{ type: mimeType }
)
})
)
imageFiles = imageFiles.concat(userImages.filter(Boolean) as Blob[])

View File

@ -5,6 +5,8 @@ import type { Chunk } from '@renderer/types/chunk'
import type { LanguageModelMiddleware } from 'ai'
import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai'
import { noThinkMiddleware } from './noThinkMiddleware'
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
/**
@ -187,6 +189,14 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
// 其他provider的通用处理
break
}
// OVMS+MCP's specific middleware
if (config.provider.id === 'ovms' && config.mcpTools && config.mcpTools.length > 0) {
builder.add({
name: 'no-think',
middleware: noThinkMiddleware()
})
}
}
/**

View File

@ -0,0 +1,52 @@
import { loggerService } from '@logger'
import type { LanguageModelMiddleware } from 'ai'
const logger = loggerService.withContext('noThinkMiddleware')
/**
* No Think Middleware
* Automatically appends ' /no_think' string to the end of user messages for the provider
* This prevents the model from generating unnecessary thinking process and returns results directly
* @returns LanguageModelMiddleware
*/
export function noThinkMiddleware(): LanguageModelMiddleware {
return {
middlewareVersion: 'v2',
transformParams: async ({ params }) => {
const transformedParams = { ...params }
// Process messages in prompt
if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) {
transformedParams.prompt = transformedParams.prompt.map((message) => {
// Only process user messages
if (message.role === 'user') {
// Process content array
if (Array.isArray(message.content)) {
const lastContent = message.content[message.content.length - 1]
// If the last content is text type, append ' /no_think'
if (lastContent && lastContent.type === 'text' && typeof lastContent.text === 'string') {
// Avoid duplicate additions
if (!lastContent.text.endsWith('/no_think')) {
logger.debug('Adding /no_think to user message')
return {
...message,
content: [
...message.content.slice(0, -1),
{
...lastContent,
text: lastContent.text + ' /no_think'
}
]
}
}
}
}
}
return message
})
}
return transformedParams
}
}
}

View File

@ -63,13 +63,14 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
// return createVertexProvider(provider)
// }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (isSystemProvider(provider)) {
if (provider.id === 'aihubmix') {
return aihubmixProviderCreator(model, provider)
}
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (provider.id === 'vertexai') {
return vertexAnthropicProviderCreator(model, provider)
}

View File

@ -39,7 +39,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
if (!match) throw new Error('Invalid base64 image format')
const mimeType = match[1]
const byteArray = Base64.toUint8Array(match[2])
const blob = new Blob([byteArray], { type: mimeType })
const blob = new Blob([byteArray as unknown as BlobPart], { type: mimeType })
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
} else if (src.startsWith('file://')) {
// 处理本地文件路径

View File

@ -101,7 +101,7 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
const { addAgent } = useAgents()
const { updateAgent } = useUpdateAgent()
// hard-coded. We only support anthropic for now.
const { models } = useApiModels({ providerType: 'anthropic' })
const { models } = useApiModels({ supportAnthropic: true })
const isEditing = (agent?: AgentWithTools) => agent !== undefined
const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent))

View File

@ -21,7 +21,7 @@ export const CodeBlockShiki = CodeBlock.extend<CodeBlockShikiOptions>({
HTMLAttributes: {
class: 'code-block-shiki'
}
}
} as CodeBlockShikiOptions
},
addInputRules() {

View File

@ -281,7 +281,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
'new-api': {
id: 'new-api',
name: 'New API',
type: 'openai',
type: 'new-api',
apiKey: '',
apiHost: 'http://localhost:3000',
anthropicApiHost: 'http://localhost:3000',
@ -1424,5 +1424,5 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
}
export const isNewApiProvider = (provider: Provider) => {
return ['new-api', 'cherryin'].includes(provider.id)
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
}

View File

@ -1,18 +1,28 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useApiServer } from '../useApiServer'
import { useAgentClient } from './useAgentClient'
export const useAgent = (id: string | null) => {
const { t } = useTranslation()
const client = useAgentClient()
const key = id ? client.agentPaths.withId(id) : null
const { apiServerConfig, apiServerRunning } = useApiServer()
const fetcher = useCallback(async () => {
if (!id || id === 'fake') {
return null
}
if (!apiServerConfig.enabled) {
throw new Error(t('apiServer.messages.notEnabled'))
}
if (!apiServerRunning) {
throw new Error(t('agent.server.error.not_running'))
}
const result = await client.getAgent(id)
return result
}, [client, id])
}, [apiServerConfig.enabled, apiServerRunning, client, id, t])
const { data, error, isLoading } = useSWR(key, id ? fetcher : null)
return {

View File

@ -6,6 +6,7 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useApiServer } from '../useApiServer'
import { useRuntime } from '../useRuntime'
import { useAgentClient } from './useAgentClient'
@ -23,11 +24,18 @@ export const useAgents = () => {
const { t } = useTranslation()
const client = useAgentClient()
const key = client.agentPaths.base
const { apiServerConfig, apiServerRunning } = useApiServer()
const fetcher = useCallback(async () => {
if (!apiServerConfig.enabled) {
throw new Error(t('apiServer.messages.notEnabled'))
}
if (!apiServerRunning) {
throw new Error(t('agent.server.error.not_running'))
}
const result = await client.listAgents()
// NOTE: We only use the array for now. useUpdateAgent depends on this behavior.
return result.data
}, [client])
}, [apiServerConfig.enabled, apiServerRunning, client, t])
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
const { chat } = useRuntime()
const { activeAgentId } = chat

View File

@ -21,7 +21,7 @@ export const useSessions = (agentId: string) => {
async (form: CreateSessionForm) => {
try {
const result = await client.createSession(agentId, form)
await mutate((prev) => [...(prev ?? []), result], { revalidate: false })
await mutate((prev) => [result, ...(prev ?? [])], { revalidate: false })
return result
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed')))

View File

@ -0,0 +1,112 @@
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setApiServerEnabled as setApiServerEnabledAction } from '@renderer/store/settings'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('useApiServer')
export const useApiServer = () => {
const { t } = useTranslation()
// FIXME: We currently store two copies of the config data in both the renderer and the main processes,
// which carries the risk of data inconsistency. This should be modified so that the main process stores
// the data, and the renderer retrieves it.
const apiServerConfig = useAppSelector((state) => state.settings.apiServer)
const dispatch = useAppDispatch()
// Optimistic initial state.
const [apiServerRunning, setApiServerRunning] = useState(apiServerConfig.enabled)
const [apiServerLoading, setApiServerLoading] = useState(true)
const setApiServerEnabled = useCallback(
(enabled: boolean) => {
dispatch(setApiServerEnabledAction(enabled))
},
[dispatch]
)
// API Server functions
const checkApiServerStatus = useCallback(async () => {
setApiServerLoading(true)
try {
const status = await window.api.apiServer.getStatus()
setApiServerRunning(status.running)
} catch (error: any) {
logger.error('Failed to check API server status:', error)
} finally {
setApiServerLoading(false)
}
}, [])
const startApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.start()
if (result.success) {
setApiServerRunning(true)
window.toast.success(t('apiServer.messages.startSuccess'))
} else {
window.toast.error(t('apiServer.messages.startError') + result.error)
}
} catch (error: any) {
window.toast.error(t('apiServer.messages.startError') + (error.message || error))
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, t])
const stopApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.stop()
if (result.success) {
setApiServerRunning(false)
window.toast.success(t('apiServer.messages.stopSuccess'))
} else {
window.toast.error(t('apiServer.messages.stopError') + result.error)
}
} catch (error: any) {
window.toast.error(t('apiServer.messages.stopError') + (error.message || error))
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, t])
const restartApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.restart()
if (result.success) {
await checkApiServerStatus()
window.toast.success(t('apiServer.messages.restartSuccess'))
} else {
window.toast.error(t('apiServer.messages.restartError') + result.error)
}
} catch (error) {
window.toast.error(t('apiServer.messages.restartFailed') + (error as Error).message)
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, checkApiServerStatus, t])
useEffect(() => {
checkApiServerStatus()
}, [checkApiServerStatus])
return {
apiServerConfig,
apiServerRunning,
apiServerLoading,
startApiServer,
stopApiServer,
restartApiServer,
checkApiServerStatus,
setApiServerEnabled
}
}

View File

@ -30,6 +30,11 @@
"failed": "Failed to list agents."
}
},
"server": {
"error": {
"not_running": "The API server is enabled but not running properly."
}
},
"session": {
"accessible_paths": {
"add": "Add directory",
@ -237,6 +242,7 @@
"messages": {
"apiKeyCopied": "API Key copied to clipboard",
"apiKeyRegenerated": "API Key regenerated",
"notEnabled": "The API Server is not enabled.",
"operationFailed": "API Server operation failed: ",
"restartError": "Failed to restart API Server: ",
"restartFailed": "API Server restart failed: ",

View File

@ -30,6 +30,11 @@
"failed": "获取智能体列表失败"
}
},
"server": {
"error": {
"not_running": "API 服务器已启用但未正常运行。"
}
},
"session": {
"accessible_paths": {
"add": "添加目录",
@ -237,6 +242,7 @@
"messages": {
"apiKeyCopied": "API 密钥已复制到剪贴板",
"apiKeyRegenerated": "API 密钥已重新生成",
"notEnabled": "API 服务器未启用。",
"operationFailed": "API 服务器操作失败:",
"restartError": "重启 API 服务器失败:",
"restartFailed": "API 服务器重启失败:",

View File

@ -30,6 +30,11 @@
"failed": "無法列出代理程式。"
}
},
"server": {
"error": {
"not_running": "API 伺服器已啟用,但運行不正常。"
}
},
"session": {
"accessible_paths": {
"add": "新增目錄",
@ -237,6 +242,7 @@
"messages": {
"apiKeyCopied": "API 金鑰已複製到剪貼簿",
"apiKeyRegenerated": "API 金鑰已重新生成",
"notEnabled": "API 伺服器未啟用。",
"operationFailed": "API 伺服器操作失敗:",
"restartError": "重新啟動 API 伺服器失敗:",
"restartFailed": "API 伺服器重新啟動失敗:",

View File

@ -30,6 +30,11 @@
"failed": "Αποτυχία καταχώρησης πρακτόρων."
}
},
"server": {
"error": {
"not_running": "Ο διακομιστής API είναι ενεργοποιημένος αλλά δεν λειτουργεί σωστά."
}
},
"session": {
"accessible_paths": {
"add": "Προσθήκη καταλόγου",
@ -237,6 +242,7 @@
"messages": {
"apiKeyCopied": "Το κλειδί API αντιγράφηκε στο πρόχειρο",
"apiKeyRegenerated": "Το κλειδί API αναδημιουργήθηκε",
"notEnabled": "Ο διακομιστής API δεν είναι ενεργοποιημένος.",
"operationFailed": "Η λειτουργία του Διακομιστή API απέτυχε: ",
"restartError": "Αποτυχία επανεκκίνησης του Διακομιστή API: ",
"restartFailed": "Η επανεκκίνηση του Διακομιστή API απέτυχε: ",

View File

@ -30,6 +30,11 @@
"failed": "Error al listar agentes."
}
},
"server": {
"error": {
"not_running": "El servidor de API está habilitado pero no funciona correctamente."
}
},
"session": {
"accessible_paths": {
"add": "Agregar directorio",
@ -237,6 +242,7 @@
"messages": {
"apiKeyCopied": "Clave API copiada al portapapeles",
"apiKeyRegenerated": "Clave API regenerada",
"notEnabled": "El servidor de API no está habilitado.",
"operationFailed": "Falló la operación del Servidor API: ",
"restartError": "Error al reiniciar el Servidor API: ",
"restartFailed": "Falló el reinicio del Servidor API: ",

View File

@ -30,6 +30,11 @@
"failed": "Échec de la liste des agents."
}
},
"server": {
"error": {
"not_running": "Le serveur API est activé mais ne fonctionne pas correctement."
}
},
"session": {
"accessible_paths": {
"add": "Ajouter un répertoire",
@ -237,6 +242,7 @@
"messages": {
"apiKeyCopied": "Clé API copiée dans le presse-papiers",
"apiKeyRegenerated": "Clé API régénérée",
"notEnabled": "Le serveur API n'est pas activé.",
"operationFailed": "Opération du Serveur API échouée : ",
"restartError": "Échec du redémarrage du Serveur API : ",
"restartFailed": "Redémarrage du Serveur API échoué : ",

View File

@ -30,6 +30,11 @@
"failed": "エージェントの一覧取得に失敗しました。"
}
},
"server": {
"error": {
"not_running": "APIサーバーは有効になっていますが、正常に動作していません。"
}
},
"session": {
"accessible_paths": {
"add": "ディレクトリを追加",
@ -237,6 +242,7 @@
"messages": {
"apiKeyCopied": "API キーがクリップボードにコピーされました",
"apiKeyRegenerated": "API キーが再生成されました",
"notEnabled": "APIサーバーが有効になっていません。",
"operationFailed": "API サーバーの操作に失敗しました:",
"restartError": "API サーバーの再起動に失敗しました:",
"restartFailed": "API サーバーの再起動に失敗しました:",

View File

@ -30,6 +30,11 @@
"failed": "Falha ao listar agentes."
}
},
"server": {
"error": {
"not_running": "O servidor de API está habilitado, mas não está funcionando corretamente."
}
},
"session": {
"accessible_paths": {
"add": "Adicionar diretório",
@ -237,6 +242,7 @@
"messages": {
"apiKeyCopied": "Chave API copiada para a área de transferência",
"apiKeyRegenerated": "Chave API regenerada",
"notEnabled": "O Servidor de API não está habilitado.",
"operationFailed": "Operação do Servidor API falhou: ",
"restartError": "Falha ao reiniciar o Servidor API: ",
"restartFailed": "Reinício do Servidor API falhou: ",

View File

@ -30,6 +30,11 @@
"failed": "Не удалось получить список агентов."
}
},
"server": {
"error": {
"not_running": "API-сервер включен, но работает неправильно."
}
},
"session": {
"accessible_paths": {
"add": "Добавить каталог",
@ -237,6 +242,7 @@
"messages": {
"apiKeyCopied": "API ключ скопирован в буфер обмена",
"apiKeyRegenerated": "API ключ перегенерирован",
"notEnabled": "API-сервер не включен.",
"operationFailed": "Операция API сервера не удалась: ",
"restartError": "Не удалось перезапустить API сервер: ",
"restartFailed": "Перезапуск API сервера не удался: ",

View File

@ -263,7 +263,9 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
value={text}
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder={t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })}
placeholder={t('chat.input.placeholder_without_triggers', {
key: getSendMessageShortcutLabel(sendMessageShortcut)
})}
autoFocus
variant="borderless"
spellCheck={enableSpellCheck}

View File

@ -1,5 +1,6 @@
import { Button } from '@heroui/button'
import CodeViewer from '@renderer/components/CodeViewer'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTimer } from '@renderer/hooks/useTimer'
import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label'
import { getProviderById } from '@renderer/services/ProviderService'
@ -33,7 +34,7 @@ import {
import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage'
import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error'
import { Alert as AntdAlert, Modal } from 'antd'
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
@ -303,14 +304,36 @@ const BuiltinError = ({ error }: { error: SerializedError }) => {
// 作为 base渲染公共字段应当在 ErrorDetailList 中渲染
const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => {
const { t } = useTranslation()
const { highlightCode } = useCodeStyle()
const [highlightedString, setHighlightedString] = useState('')
const cause = error.cause
useEffect(() => {
const highlight = async () => {
try {
const result = await highlightCode(JSON.stringify(JSON.parse(cause || '{}'), null, 2), 'json')
setHighlightedString(result)
} catch {
setHighlightedString(cause || '')
}
}
const timer = setTimeout(highlight, 0)
return () => clearTimeout(timer)
}, [highlightCode, cause])
return (
<>
<BuiltinError error={error} />
{cause && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.cause')}:</ErrorDetailLabel>
<ErrorDetailValue>{error.cause}</ErrorDetailValue>
<ErrorDetailValue>
<div
className="markdown [&_pre]:!bg-transparent [&_pre_span]:whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: highlightedString }}
/>
</ErrorDetailValue>
</ErrorDetailItem>
)}
</>

View File

@ -62,7 +62,7 @@ const MessageImage: FC<Props> = ({ block }) => {
byteArrays.push(byteArray)
}
const blob = new Blob(byteArrays, { type: mimeType })
const blob = new Blob(byteArrays as unknown as BlobPart[], { type: mimeType })
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
} else {
throw new Error('无效的 base64 图片格式')

View File

@ -1,15 +1,16 @@
import { Alert, Spinner } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiServer } from '@renderer/hooks/useApiServer'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAssistantsTabSortType } from '@renderer/hooks/useStore'
import { useTags } from '@renderer/hooks/useTags'
import { useAppDispatch } from '@renderer/store'
import { addIknowAction } from '@renderer/store/runtime'
import type { Assistant } from '@renderer/types'
import type { Assistant } from '@types'
import { getErrorMessage } from '@renderer/utils'
import type { AssistantTabSortType } from '@shared/data/preference/preferenceTypes'
import type { FC } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
@ -37,7 +38,8 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props
const containerRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
const { apiServer } = useSettings()
const { apiServerConfig, apiServerRunning } = useApiServer()
const apiServerEnabled = apiServerConfig.enabled
const { iknow, chat } = useRuntime()
const dispatch = useAppDispatch()
@ -57,7 +59,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const { unifiedItems, handleUnifiedListReorder } = useUnifiedItems({
agents,
assistants,
apiServerEnabled: apiServer.enabled,
apiServerEnabled,
agentsLoading,
agentsError,
updateAssistants
@ -74,17 +76,17 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
unifiedItems,
assistants,
agents,
apiServerEnabled: apiServer.enabled,
apiServerEnabled,
agentsLoading,
agentsError,
updateAssistants
})
useEffect(() => {
if (!agentsLoading && agents.length > 0 && !activeAgentId && apiServer.enabled) {
if (!agentsLoading && agents.length > 0 && !activeAgentId && apiServerConfig.enabled) {
setActiveAgentId(agents[0].id)
}
}, [agentsLoading, agents, activeAgentId, setActiveAgentId, apiServer.enabled])
}, [agentsLoading, agents, activeAgentId, setActiveAgentId, apiServerConfig.enabled])
const onDeleteAssistant = useCallback(
(assistant: Assistant) => {
@ -107,7 +109,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
return (
<Container className="assistants-tab" ref={containerRef}>
{!apiServer.enabled && !iknow[ALERT_KEY] && (
{!apiServerConfig.enabled && !iknow[ALERT_KEY] && (
<Alert
color="warning"
title={t('agent.warning.enable_server')}
@ -115,11 +117,22 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
onClose={() => {
dispatch(addIknowAction(ALERT_KEY))
}}
className="mb-2"
/>
)}
{agentsLoading && <Spinner />}
{apiServer.enabled && agentsError && <Alert color="danger" title={t('agent.list.error.failed')} />}
{apiServerConfig.enabled && !apiServerRunning && (
<Alert color="danger" title={t('agent.server.error.not_running')} isClosable className="mb-2" />
)}
{apiServerConfig.enabled && apiServerRunning && agentsError && (
<Alert
color="danger"
title={t('agent.list.error.failed')}
description={getErrorMessage(agentsError)}
className="mb-2"
/>
)}
{assistantsTabSortType === 'tags' ? (
<UnifiedTagGroups

View File

@ -297,13 +297,7 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
label: t('chat.topics.clear.title'),
key: 'clear-messages',
icon: <BrushCleaning size={14} />,
async onClick() {
window.modal.confirm({
title: t('chat.input.clear.content'),
centered: true,
onOk: () => onClearMessages(topic)
})
}
onClick: () => onClearMessages(topic)
},
{
label: t('settings.topic.position.label'),

View File

@ -47,15 +47,58 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
setActiveIndex(0)
}, [])
const stopSearch = useCallback(() => {
const target = webviewRef.current ?? attachedWebviewRef.current
if (!target) return
try {
target.stopFindInPage('clearSelection')
} catch (error) {
logger.error('stopFindInPage failed', { error })
const ensureWebviewReady = useCallback(
(candidate: WebviewTag | null) => {
if (!candidate) return null
try {
const webContentsId = candidate.getWebContentsId?.()
if (!webContentsId) {
logger.debug('WebviewSearch: missing webContentsId before action', { appId })
return null
}
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed before action', { appId, error })
return null
}
return candidate
},
[appId]
)
const stopFindOnWebview = useCallback(
(webview: WebviewTag | null) => {
const usable = ensureWebviewReady(webview)
if (!usable) return false
try {
usable.stopFindInPage('clearSelection')
return true
} catch (error) {
logger.debug('stopFindInPage failed', { appId, error })
return false
}
},
[appId, ensureWebviewReady]
)
const getUsableWebview = useCallback(() => {
const candidates = [webviewRef.current, attachedWebviewRef.current]
for (const candidate of candidates) {
const usable = ensureWebviewReady(candidate)
if (usable) {
return usable
}
}
}, [webviewRef])
return null
}, [ensureWebviewReady, webviewRef])
const stopSearch = useCallback(() => {
const target = getUsableWebview()
if (!target) return
stopFindOnWebview(target)
}, [getUsableWebview, stopFindOnWebview])
const closeSearch = useCallback(() => {
setIsVisible(false)
@ -65,7 +108,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
const performSearch = useCallback(
(text: string, options?: Electron.FindInPageOptions) => {
const target = webviewRef.current ?? attachedWebviewRef.current
const target = getUsableWebview()
if (!target) {
logger.debug('Skip performSearch: webview not attached')
return
@ -82,7 +125,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
window.toast?.error(t('common.error'))
}
},
[resetSearchState, stopSearch, t, webviewRef]
[getUsableWebview, resetSearchState, stopSearch, t]
)
const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
@ -130,22 +173,26 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
return () => {
activeWebview.removeEventListener('found-in-page', handle)
if (attachedWebviewRef.current === activeWebview) {
try {
activeWebview.stopFindInPage('clearSelection')
} catch (error) {
logger.error('stopFindInPage failed', { error })
}
stopFindOnWebview(activeWebview)
attachedWebviewRef.current = null
}
}
}, [activeWebview, handleFoundInPage])
}, [activeWebview, handleFoundInPage, stopFindOnWebview])
useEffect(() => {
if (!activeWebview) return
if (!isWebviewReady) return
const onFindShortcut = window.api?.webview?.onFindShortcut
if (!onFindShortcut) return
const webContentsId = activeWebview.getWebContentsId?.()
let webContentsId: number | undefined
try {
webContentsId = activeWebview.getWebContentsId?.()
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed', { appId, error })
return
}
if (!webContentsId) {
logger.warn('WebviewSearch: missing webContentsId', { appId })
return
@ -178,7 +225,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
return () => {
unsubscribe?.()
}
}, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, openSearch])
}, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, isWebviewReady, openSearch])
useEffect(() => {
if (!isVisible) return

View File

@ -164,6 +164,61 @@ describe('WebviewSearch', () => {
})
})
it('skips shortcut wiring when getWebContentsId throws', async () => {
const { webview } = createWebviewMock()
const error = new Error('not ready')
;(webview as any).getWebContentsId = vi.fn(() => {
throw error
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
expect(onFindShortcutMock).not.toHaveBeenCalled()
;(webview as any).getWebContentsId = vi.fn(() => 1)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
})
it('does not call stopFindInPage when webview is not ready', async () => {
const { stopFindInPageMock, webview } = createWebviewMock()
const error = new Error('loading')
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { rerender, unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
stopFindInPageMock.mockImplementation(() => {
throw new Error('should not be called')
})
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
expect(stopFindInPageMock).not.toHaveBeenCalled()
unmount()
expect(stopFindInPageMock).not.toHaveBeenCalled()
})
it('closes the search overlay when escape is forwarded from the webview', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>

View File

@ -14,7 +14,6 @@ import { LanguagesEnum } from '@renderer/config/translate'
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import type { FileMetadata } from '@renderer/types'
@ -32,6 +31,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { type ConfigItem, createModeConfigs, DEFAULT_PAINTING } from './config/aihubmixConfig'
import { checkProviderEnabled } from './utils'
@ -73,20 +73,6 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
const { t } = useTranslation()
const { theme } = useTheme()
const providers = useAllProviders()
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const [generating, setGenerating] = useCache('chat.generating')
const navigate = useNavigate()
const location = useLocation()
@ -841,21 +827,12 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
/>
</SettingHelpLink>
</ProviderTitleContainer>
<Select value={providerOptions[1].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<Avatar
radius="md"
src={getProviderLogo(provider.value || '')}
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
/>
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
<ProviderSelect
provider={aihubmixProvider}
options={Options}
onChange={handleProviderChange}
className={'mb-4'}
/>
{/* 使用JSON配置渲染设置项 */}
{modeConfigs[mode].filter((item) => (item.condition ? item.condition(painting) : true)).map(renderConfigItem)}
@ -1013,12 +990,6 @@ const ModeSegmentedContainer = styled.div`
padding-top: 24px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
// 添加新的样式组件
const ProviderTitleContainer = styled.div`
display: flex;

View File

@ -8,7 +8,6 @@ import { isMac } from '@renderer/config/constant'
import { getProviderLogo } from '@renderer/config/providers'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager'
import type { FileMetadata } from '@renderer/types'
import { convertToBase64, uuid } from '@renderer/utils'
@ -27,6 +26,7 @@ import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import ImageUploader from './components/ImageUploader'
import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import {
COURSE_URL,
DEFAULT_PAINTING,
@ -44,20 +44,6 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
const [painting, setPainting] = useState<DmxapiPainting>(dmxapi_paintings?.[0] || DEFAULT_PAINTING)
const { t } = useTranslation()
const providers = useAllProviders()
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const dmxapiProvider = providers.find((p) => p.id === 'dmxapi')!
@ -782,9 +768,9 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
return (
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter>
<NavbarCenter className="border-r-0">{t('paintings.title')}</NavbarCenter>
{isMac && (
<NavbarRight style={{ justifyContent: 'flex-end' }}>
<NavbarRight className="justify-end">
<Button size="sm" className="nodrag" startContent={<PlusOutlined />} onPress={createNewPainting}>
{t('paintings.button.new.image')}
</Button>
@ -794,7 +780,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container">
<LeftContainer>
<ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<SettingTitle className="mb-1">{t('common.provider')}</SettingTitle>
<div>
<SettingHelpLink target="_blank" href={COURSE_URL}>
{t('paintings.paint_course')}
@ -802,32 +788,19 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
<SettingHelpLink target="_blank" href={TOP_UP_URL}>
{t('paintings.top_up')}
</SettingHelpLink>
<Avatar
radius="md"
src={getProviderLogo(dmxapiProvider.id)}
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
style={{ marginLeft: 5 }}
/>
<ProviderLogo radius="md" src={getProviderLogo(dmxapiProvider.id)} className="ml-1" />
</div>
</ProviderTitleContainer>
<Select value={providerOptions[3].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<Avatar
radius="md"
src={getProviderLogo(provider.value || '')}
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
/>
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
<ProviderSelect
provider={dmxapiProvider}
options={Options}
onChange={handleProviderChange}
className="mb-4"
/>
{painting.generationMode &&
[generationModeType.EDIT, generationModeType.MERGE].includes(painting.generationMode) && (
<>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}></SettingTitle>
<SettingTitle className="mt-4 mb-1"></SettingTitle>
<ImageUploader
fileMap={fileMap}
maxImages={painting.generationMode === generationModeType.EDIT ? 1 : 3}
@ -839,13 +812,13 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</>
)}
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
<SettingTitle className="mt-4 mb-1">
{t('common.model')} <SettingPrice>{painting.priceModel !== '0' ? painting.priceModel : ''}</SettingPrice>
</SettingTitle>
<Select
value={painting.model}
onChange={onSelectModel}
style={{ width: '100%' }}
className="w-full"
loading={isLoadingModels}
placeholder={isLoadingModels ? t('common.loading') : t('paintings.select_model')}>
{Object.entries(modelOptions).map(([provider, models]) => {
@ -862,11 +835,11 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
})}
</Select>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>
<SettingTitle className="mt-4 mb-1">{t('paintings.image.size')}</SettingTitle>
<Select
value={isCustomSize ? 'custom' : painting.image_size}
onChange={(value) => onSelectImageSize(value)}
style={{ width: '100%' }}>
className="w-full">
{(() => {
const currentModel = allModels.find((m) => m.id === painting.model)
const modelImageSizes = currentModel?.image_sizes || []
@ -894,7 +867,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{/* 自定义尺寸输入框 */}
{isCustomSize && allModels.find((m) => m.id === painting.model)?.is_custom_size && (
<div style={{ marginTop: 10 }}>
<div className="mt-2.5">
<RowFlex className="items-center gap-2">
<InputNumber
placeholder="W"
@ -922,7 +895,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{painting.generationMode === generationModeType.GENERATION && (
<>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
<SettingTitle className="mt-4 mb-1">
{t('paintings.seed')}
<InfoTooltip content={t('paintings.seed_desc_tip')} />
</SettingTitle>
@ -940,7 +913,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</>
)}
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.style_type')}</SettingTitle>
<SettingTitle className="mt-4 mb-1">{t('paintings.style_type')}</SettingTitle>
<SliderContainer>
<RadioTextBox>
{STYLE_TYPE_OPTIONS.map((ele) => (
@ -954,7 +927,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</RadioTextBox>
</SliderContainer>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
<SettingTitle className="mt-4 mb-1">
{t('paintings.auto_create_paint')}
<InfoTooltip content={t('paintings.auto_create_paint_tip')} />
</SettingTitle>
@ -1026,10 +999,8 @@ const ProviderTitleContainer = styled.div`
margin-bottom: 5px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border);
`
const ContentContainer = styled.div`

View File

@ -10,7 +10,7 @@ import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navb
import Scrollbar from '@renderer/components/Scrollbar'
import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { getProviderLogo } from '@renderer/config/providers'
import { getProviderLogo, isNewApiProvider, PROVIDER_URLS } from '@renderer/config/providers'
import { LanguagesEnum } from '@renderer/config/translate'
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings'
@ -19,8 +19,7 @@ import {
getPaintingsBackgroundOptionsLabel,
getPaintingsImageSizeOptionsLabel,
getPaintingsModerationOptionsLabel,
getPaintingsQualityOptionsLabel,
getProviderLabel
getPaintingsQualityOptionsLabel
} from '@renderer/i18n/label'
import PaintingsList from '@renderer/pages/paintings/components/PaintingsList'
import { DEFAULT_PAINTING, MODELS, SUPPORTED_MODELS } from '@renderer/pages/paintings/config/NewApiConfig'
@ -41,6 +40,7 @@ import styled from 'styled-components'
import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import ProviderSelect from './components/ProviderSelect'
import { checkProviderEnabled } from './utils'
const logger = loggerService.withContext('NewApiPage')
@ -56,8 +56,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
}
}, [openai_image_generate, openai_image_edit])
const filteredPaintings = useMemo(() => newApiPaintings[mode] || [], [newApiPaintings, mode])
const [painting, setPainting] = useState<PaintingAction>(filteredPaintings[0] || DEFAULT_PAINTING)
// moved below after newApiProvider is defined
const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [abortController, setAbortController] = useState<AbortController | null>(null)
@ -68,26 +67,21 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const { t } = useTranslation()
const { theme } = useTheme()
const providers = useAllProviders()
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const location = useLocation()
const routeName = location.pathname.split('/').pop() || 'new-api'
const newApiProviders = providers.filter((p) => isNewApiProvider(p))
const [generating, setGenerating] = useCache('chat.generating')
const navigate = useNavigate()
const location = useLocation()
const [autoTranslateWithSpace] = usePreference('chat.input.translate.auto_translate_with_space')
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const newApiProvider = providers.find((p) => p.id === 'new-api')!
const newApiProvider = newApiProviders.find((p) => p.id === routeName) || newApiProviders[0]
const filteredPaintings = useMemo(
() => (newApiPaintings[mode] || []).filter((p) => p.providerId === newApiProvider.id),
[newApiPaintings, mode, newApiProvider.id]
)
const [painting, setPainting] = useState<PaintingAction>({ ...DEFAULT_PAINTING, providerId: newApiProvider.id })
const modeOptions = [
{ label: t('paintings.mode.generate'), value: 'openai_image_generate' },
@ -102,7 +96,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
}, [editImageFiles])
const updatePaintingState = (updates: Partial<PaintingAction>) => {
const updatedPainting = { ...painting, ...updates }
const updatedPainting = { ...painting, providerId: newApiProvider.id, ...updates }
setPainting(updatedPainting)
updatePainting(mode, updatedPainting)
}
@ -138,9 +132,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
return {
...DEFAULT_PAINTING,
model: painting.model || modelOptions[0]?.value || '',
id: uuid()
id: uuid(),
providerId: newApiProvider.id
}
}, [modelOptions, painting.model])
}, [modelOptions, painting.model, newApiProvider.id])
const selectedModelConfig = useMemo(
() => MODELS.find((m) => m.name === painting.model) || MODELS[0],
@ -444,11 +439,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
// 处理模式切换
const handleModeChange = (value: string) => {
setMode(value as keyof PaintingsState)
if (newApiPaintings[value as keyof PaintingsState] && newApiPaintings[value as keyof PaintingsState].length > 0) {
setPainting(newApiPaintings[value as keyof PaintingsState][0])
} else {
setPainting(DEFAULT_PAINTING)
}
const list = (newApiPaintings[value as keyof PaintingsState] || []).filter(
(p) => p.providerId === newApiProvider.id
)
setPainting(list[0] || { ...DEFAULT_PAINTING, providerId: newApiProvider.id })
}
// 渲染配置项的函数
@ -473,8 +467,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const newPainting = getNewPainting()
addPainting(mode, newPainting)
setPainting(newPainting)
} else {
setPainting(filteredPaintings[0])
}
}, [filteredPaintings, mode, addPainting, painting, getNewPainting])
}, [filteredPaintings, mode, addPainting, getNewPainting])
useEffect(() => {
const timer = spaceClickTimer.current
@ -501,9 +497,11 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
<LeftContainer>
<ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<SettingHelpLink target="_blank" href={'https://docs.newapi.pro/apps/cherry-studio/'}>
<SettingHelpLink
target="_blank"
href={PROVIDER_URLS[newApiProvider.id]?.websites?.docs || 'https://docs.newapi.pro/apps/cherry-studio/'}>
{t('paintings.learn_more')}
<Avatar
<ProviderLogo
radius="md"
src={getProviderLogo(newApiProvider.id)}
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
@ -512,23 +510,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
</SettingHelpLink>
</ProviderTitleContainer>
<Select
value={providerOptions.find((p) => p.value === 'new-api')?.value}
onChange={handleProviderChange}
style={{ width: '100%' }}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<Avatar
radius="md"
src={getProviderLogo(provider.value || '')}
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
/>
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
<ProviderSelect provider={newApiProvider} options={Options} onChange={handleProviderChange} />
{/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */}
{modelOptions.length === 0 && (
@ -792,20 +774,16 @@ const ToolbarMenu = styled.div`
gap: 6px;
`
// 添加新的样式组件
const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border);
`
const ModeSegmentedContainer = styled.div`
display: flex;
justify-content: center;
padding-top: 24px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
// 添加新的样式组件
const ProviderTitleContainer = styled.div`
display: flex;
justify-content: space-between;

View File

@ -1,9 +1,11 @@
import { loggerService } from '@logger'
import { isNewApiProvider } from '@renderer/config/providers'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useAppDispatch } from '@renderer/store'
import { setDefaultPaintingProvider } from '@renderer/store/settings'
import type { PaintingProvider } from '@renderer/types'
import type { PaintingProvider, SystemProviderId } from '@renderer/types'
import type { FC } from 'react'
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { Route, Routes, useParams } from 'react-router-dom'
import AihubmixPage from './AihubmixPage'
@ -15,19 +17,23 @@ import ZhipuPage from './ZhipuPage'
const logger = loggerService.withContext('PaintingsRoutePage')
const Options = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux', 'new-api']
const BASE_OPTIONS: SystemProviderId[] = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux']
const PaintingsRoutePage: FC = () => {
const params = useParams()
const provider = params['*']
const dispatch = useAppDispatch()
const providers = useAllProviders()
const Options = useMemo(() => {
return [...BASE_OPTIONS, ...providers.filter((p) => isNewApiProvider(p)).map((p) => p.id)]
}, [providers])
useEffect(() => {
logger.debug(`defaultPaintingProvider: ${provider}`)
if (provider && Options.includes(provider)) {
dispatch(setDefaultPaintingProvider(provider as PaintingProvider))
}
}, [provider, dispatch])
}, [provider, dispatch, Options])
return (
<Routes>
@ -37,7 +43,12 @@ const PaintingsRoutePage: FC = () => {
<Route path="/silicon" element={<SiliconPage Options={Options} />} />
<Route path="/dmxapi" element={<DmxapiPage Options={Options} />} />
<Route path="/tokenflux" element={<TokenFluxPage Options={Options} />} />
<Route path="/new-api" element={<NewApiPage Options={Options} />} />
{/* new-api family providers are mounted dynamically below */}
{providers
.filter((p) => isNewApiProvider(p))
.map((p) => (
<Route key={p.id} path={`/${p.id}`} element={<NewApiPage Options={Options} />} />
))}
</Routes>
)
}

View File

@ -14,18 +14,16 @@ import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navb
import Scrollbar from '@renderer/components/Scrollbar'
import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { getProviderLogo } from '@renderer/config/providers'
import { LanguagesEnum } from '@renderer/config/translate'
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { getProviderLabel } from '@renderer/i18n/label'
import { getProviderByModel } from '@renderer/services/AssistantService'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import type { FileMetadata, Painting } from '@renderer/types'
import { getErrorMessage, uuid } from '@renderer/utils'
import { Avatar, Input, InputNumber, Radio, Select, Slider } from 'antd'
import { Input, InputNumber, Radio, Select, Slider } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import type { FC } from 'react'
import { useEffect, useRef, useState } from 'react'
@ -37,6 +35,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { checkProviderEnabled } from './utils'
export const TEXT_TO_IMAGES_MODELS = [
@ -112,22 +111,8 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
const [painting, setPainting] = useState<Painting>(siliconflow_paintings[0] || DEFAULT_PAINTING)
const { theme } = useTheme()
const providers = useAllProviders()
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const siliconflowProvider = providers.find((p) => p.id === 'silicon')
const siliconFlowProvider = providers.find((p) => p.id === 'silicon')!
const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false)
@ -166,7 +151,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
}
const onGenerate = async () => {
await checkProviderEnabled(siliconflowProvider!, t)
await checkProviderEnabled(siliconFlowProvider!, t)
if (painting.files.length > 0) {
const confirmed = await window.modal.confirm({
@ -385,17 +370,8 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container">
<LeftContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<Select value={providerOptions[2].value} onChange={handleProviderChange}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
<ProviderSelect provider={siliconFlowProvider} options={Options} onChange={handleProviderChange} />
<SettingTitle className="mt-4 mb-1">{t('common.model')}</SettingTitle>
<Select value={painting.model} options={modelOptions} onChange={onSelectModel} />
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>
<Radio.Group
@ -643,14 +619,4 @@ const StyledInputNumber = styled(InputNumber)`
width: 70px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const ProviderLogo = styled(Avatar)`
flex-shrink: 0;
`
export default SiliconPage

View File

@ -11,7 +11,6 @@ import { getProviderLogo } from '@renderer/config/providers'
import { LanguagesEnum } from '@renderer/config/translate'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import type { TokenFluxPainting } from '@renderer/types'
@ -29,6 +28,7 @@ import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import { DynamicFormRender } from './components/DynamicFormRender'
import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig'
import { checkProviderEnabled } from './utils'
import TokenFluxService from './utils/TokenFluxService'
@ -54,21 +54,6 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
tokenFluxPaintings[0] || { ...DEFAULT_TOKENFLUX_PAINTING, id: uuid() }
)
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const navigate = useNavigate()
const location = useLocation()
const [autoTranslateWithSpace] = usePreference('chat.input.translate.auto_translate_with_space')
@ -388,23 +373,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
</SettingHelpLink>
</ProviderTitleContainer>
<Select
value={providerOptions.find((p) => p.value === 'tokenflux')?.value}
onChange={handleProviderChange}
style={{ width: '100%' }}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<Avatar
radius="md"
src={getProviderLogo(provider.value || '')}
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
/>
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
<ProviderSelect provider={tokenfluxProvider} options={Options} onChange={handleProviderChange} />
{/* Model & Pricing Section */}
<SectionTitle
@ -759,11 +728,4 @@ const ProviderTitleContainer = styled.div`
align-items: center;
margin-bottom: 5px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
export default TokenFluxPage

View File

@ -10,7 +10,6 @@ import { isMac } from '@renderer/config/constant'
import { getProviderLogo } from '@renderer/config/providers'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager'
import { getErrorMessage, uuid } from '@renderer/utils'
import { InputNumber, Radio, Select } from 'antd'
@ -25,6 +24,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import {
COURSE_URL,
DEFAULT_PAINTING,
@ -51,21 +51,6 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [painting?.id]) // 只在painting的id改变时执行避免无限循环
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const zhipuProvider = providers.find((p) => p.id === 'zhipu')!
const [currentImageIndex, setCurrentImageIndex] = useState(0)
@ -375,16 +360,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => {
/>
</div>
</ProviderTitleContainer>
<Select value={providerOptions[0].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<ProviderLogo radius="md" src={getProviderLogo(provider.value || '')} className="h-4 w-4" />
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
<ProviderSelect provider={zhipuProvider} options={Options} onChange={handleProviderChange} className="mb-4" />
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
<Select
@ -568,12 +544,6 @@ const ProviderTitleContainer = styled.div`
margin-bottom: 10px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const ProviderLogo = styled(Avatar)`
border-radius: 4px;
`

View File

@ -0,0 +1,100 @@
import { Select, SelectItem } from '@heroui/react'
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
import { getProviderLogo } from '@renderer/config/providers'
import ImageStorage from '@renderer/services/ImageStorage'
import { getProviderNameById } from '@renderer/services/ProviderService'
import type { Provider } from '@types'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
type ProviderSelectProps = {
provider: Provider
options: string[]
onChange: (value: string) => void
style?: React.CSSProperties
className?: string
}
const ProviderSelect: FC<ProviderSelectProps> = ({ provider, options, onChange, style, className }) => {
const [customLogos, setCustomLogos] = useState<Record<string, string>>({})
useEffect(() => {
const loadLogos = async () => {
const logos: Record<string, string> = {}
for (const providerId of options) {
try {
const logoData = await ImageStorage.get(`provider-${providerId}`)
if (logoData) {
logos[providerId] = logoData
}
} catch (error) {
// Ignore errors for providers without custom logos
}
}
setCustomLogos(logos)
}
loadLogos()
}, [options])
const getProviderLogoSrc = (providerId: string) => {
const systemLogo = getProviderLogo(providerId)
if (systemLogo) {
return systemLogo
}
return customLogos[providerId]
}
const providerOptions = options.map((option) => {
return {
label: getProviderNameById(option),
value: option
}
})
return (
<Select
selectedKeys={[provider.id]}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string
onChange(selectedKey)
}}
style={style}
className={`w-full ${className || ''}`}
renderValue={(items) => {
return items.map((item) => (
<div key={item.key} className="flex items-center gap-2">
<div className="flex h-4 w-4 items-center justify-center">
<ProviderAvatarPrimitive
providerId={item.key as string}
providerName={item.textValue || ''}
logoSrc={getProviderLogoSrc(item.key as string)}
size={16}
/>
</div>
<span>{item.textValue}</span>
</div>
))
}}>
{providerOptions.map((providerOption) => (
<SelectItem
key={providerOption.value}
textValue={providerOption.label}
startContent={
<div className="flex h-4 w-4 items-center justify-center">
<ProviderAvatarPrimitive
providerId={providerOption.value}
providerName={providerOption.label}
logoSrc={getProviderLogoSrc(providerOption.value)}
size={16}
/>
</div>
}>
{providerOption.label}
</SelectItem>
))}
</Select>
)
}
export default ProviderSelect

View File

@ -252,7 +252,8 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
{ label: 'OpenAI-Response', value: 'openai-response' },
{ label: 'Gemini', value: 'gemini' },
{ label: 'Anthropic', value: 'anthropic' },
{ label: 'Azure OpenAI', value: 'azure-openai' }
{ label: 'Azure OpenAI', value: 'azure-openai' },
{ label: 'New API', value: 'new-api' }
]}
/>
</Form.Item>

View File

@ -1,16 +1,14 @@
// TODO: Refactor this component to use HeroUI
import { Button, Tooltip } from '@cherrystudio/ui'
import { useTheme } from '@renderer/context/ThemeProvider'
import { loggerService } from '@renderer/services/LoggerService'
import { useApiServer } from '@renderer/hooks/useApiServer'
import type { RootState } from '@renderer/store'
import { useAppDispatch } from '@renderer/store'
import { setApiServerApiKey, setApiServerEnabled, setApiServerPort } from '@renderer/store/settings'
import { setApiServerApiKey, setApiServerPort } from '@renderer/store/settings'
import { formatErrorMessage } from '@renderer/utils/error'
import { IpcChannel } from '@shared/IpcChannel'
import { Input, InputNumber, Typography } from 'antd'
import { Copy, ExternalLink, Play, RotateCcw, Square } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
@ -18,7 +16,6 @@ import { v4 as uuidv4 } from 'uuid'
import { SettingContainer } from '../..'
const logger = loggerService.withContext('ApiServerSettings')
const { Text, Title } = Typography
const ApiServerSettings: FC = () => {
@ -28,67 +25,25 @@ const ApiServerSettings: FC = () => {
// API Server state with proper defaults
const apiServerConfig = useSelector((state: RootState) => state.settings.apiServer)
const [apiServerRunning, setApiServerRunning] = useState(false)
const [apiServerLoading, setApiServerLoading] = useState(false)
// API Server functions
const checkApiServerStatus = async () => {
try {
const status = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus)
setApiServerRunning(status.running)
} catch (error: any) {
logger.error('Failed to check API server status:', error)
}
}
useEffect(() => {
checkApiServerStatus()
}, [])
const { apiServerRunning, apiServerLoading, startApiServer, stopApiServer, restartApiServer, setApiServerEnabled } =
useApiServer()
const handleApiServerToggle = async (enabled: boolean) => {
setApiServerLoading(true)
try {
if (enabled) {
const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Start)
if (result.success) {
setApiServerRunning(true)
window.toast.success(t('apiServer.messages.startSuccess'))
} else {
window.toast.error(t('apiServer.messages.startError') + result.error)
}
await startApiServer()
} else {
const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
if (result.success) {
setApiServerRunning(false)
window.toast.success(t('apiServer.messages.stopSuccess'))
} else {
window.toast.error(t('apiServer.messages.stopError') + result.error)
}
await stopApiServer()
}
} catch (error) {
window.toast.error(t('apiServer.messages.operationFailed') + formatErrorMessage(error))
} finally {
dispatch(setApiServerEnabled(enabled))
setApiServerLoading(false)
setApiServerEnabled(enabled)
}
}
const handleApiServerRestart = async () => {
setApiServerLoading(true)
try {
const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Restart)
if (result.success) {
await checkApiServerStatus()
window.toast.success(t('apiServer.messages.restartSuccess'))
} else {
window.toast.error(t('apiServer.messages.restartError') + result.error)
}
} catch (error) {
window.toast.error(t('apiServer.messages.restartFailed') + (error as Error).message)
} finally {
setApiServerLoading(false)
}
await restartApiServer()
}
const copyApiKey = () => {

View File

@ -6,6 +6,7 @@ import { ProviderTypeSchema } from './provider'
// Request schema for /v1/models
export const ApiModelsFilterSchema = z.object({
providerType: ProviderTypeSchema.optional(),
supportAnthropic: z.coerce.boolean().optional(),
offset: z.coerce.number().min(0).default(0).optional(),
limit: z.coerce.number().min(1).default(20).optional()
})

View File

@ -0,0 +1,38 @@
export type ApiServerConfig = {
enabled: boolean
host: string
port: number
apiKey: string
}
export type GetApiServerStatusResult = {
running: boolean
config: ApiServerConfig | null
}
export type StartApiServerStatusResult =
| {
success: true
}
| {
success: false
error: string
}
export type RestartApiServerStatusResult =
| {
success: true
}
| {
success: false
error: string
}
export type StopApiServerStatusResult =
| {
success: true
}
| {
success: false
error: string
}

View File

@ -20,6 +20,7 @@ import type {
UnsupportedFunctionalityError
} from 'ai'
import type { ProviderSpecificError } from './provider-specific-error'
import type { Serializable } from './serialize'
export interface SerializedError {
@ -80,7 +81,7 @@ export interface SerializedAiSdkInvalidArgumentError extends SerializedAiSdkErro
export const isSerializedAiSdkInvalidArgumentError = (
error: SerializedError
): error is SerializedAiSdkInvalidArgumentError => {
return isSerializedAiSdkError(error) && 'parameter' in error && 'value' in error
return isSerializedAiSdkError(error) && 'message' in error && error.name === 'AI_InvalidArgumentError'
}
export interface SerializedAiSdkInvalidDataContentError extends SerializedAiSdkError {
@ -198,10 +199,20 @@ export interface SerializedAiSdkNoSuchToolError extends SerializedAiSdkError {
readonly availableTools: string[] | null
}
export interface SerializedAiSdkProviderSpecificError extends SerializedAiSdkError {
readonly provider: string
}
export const isSerializedAiSdkNoSuchToolError = (error: SerializedError): error is SerializedAiSdkNoSuchToolError => {
return isSerializedAiSdkError(error) && 'toolName' in error && 'availableTools' in error
}
export const isSerializedAiSdkProviderSpecificError = (
error: SerializedError
): error is SerializedAiSdkProviderSpecificError => {
return isSerializedAiSdkError(error) && 'provider' in error
}
export interface SerializedAiSdkRetryError extends SerializedAiSdkError {
readonly reason: string
readonly lastError: Serializable
@ -277,6 +288,7 @@ export type AiSdkErrorUnion =
| NoSuchModelError
| NoSuchProviderError
| NoSuchToolError
| ProviderSpecificError
| RetryError
| ToolCallRepairError
| TypeValidationError
@ -297,6 +309,7 @@ export type SerializedAiSdkErrorUnion =
| SerializedAiSdkNoSuchModelError
| SerializedAiSdkNoSuchProviderError
| SerializedAiSdkNoSuchToolError
| SerializedAiSdkProviderSpecificError
| SerializedAiSdkRetryError
| SerializedAiSdkToolCallRepairError
| SerializedAiSdkTypeValidationError
@ -317,6 +330,7 @@ export const isSerializedAiSdkErrorUnion = (error: SerializedError): error is Se
isSerializedAiSdkNoSuchModelError(error) ||
isSerializedAiSdkNoSuchProviderError(error) ||
isSerializedAiSdkNoSuchToolError(error) ||
isSerializedAiSdkProviderSpecificError(error) ||
isSerializedAiSdkRetryError(error) ||
isSerializedAiSdkToolCallRepairError(error) ||
isSerializedAiSdkTypeValidationError(error) ||

View File

@ -17,6 +17,7 @@ import type { BaseTool, MCPTool } from './tool'
export * from './agent'
export * from './apiModels'
export * from './apiServer'
export * from './knowledge'
export * from './mcp'
export * from './notification'
@ -272,6 +273,8 @@ export type PaintingParams = {
id: string
urls: string[]
files: FileMetadata[]
// provider that this painting belongs to (for new-api family separation)
providerId?: string
}
export type PaintingProvider = 'zhipu' | 'aihubmix' | 'silicon' | 'dmxapi' | 'new-api'
@ -843,13 +846,6 @@ export type S3Config = {
}
export type { Message } from './newMessage'
export interface ApiServerConfig {
enabled: boolean
host: string
port: number
apiKey: string
}
export * from './tool'
// Memory Service Types

View File

@ -0,0 +1,29 @@
import { AISDKError } from 'ai'
const name = 'AI_ProviderSpecificError'
const marker = `vercel.ai.error.${name}`
const symbol = Symbol.for(marker)
export class ProviderSpecificError extends AISDKError {
// @ts-ignore
private readonly [symbol] = true // used in isInstance
readonly provider: string
constructor({
message,
provider,
cause
}: {
message: string
provider: string
cause?: unknown
}) {
super({ name, message, cause })
this.provider = provider
}
static isInstance(error: unknown): error is ProviderSpecificError {
return AISDKError.hasMarker(error, marker)
}
}

View File

@ -11,7 +11,8 @@ export const ProviderTypeSchema = z.enum([
'vertexai',
'mistral',
'aws-bedrock',
'vertex-anthropic'
'vertex-anthropic',
'new-api'
])
export type ProviderType = z.infer<typeof ProviderTypeSchema>

View File

@ -18,7 +18,7 @@ export const getModelFilterByAgentType = (type: AgentType): ApiModelsFilter => {
switch (type) {
case 'claude-code':
return {
providerType: 'anthropic'
supportAnthropic: true
}
default:
return {}

View File

@ -201,6 +201,7 @@ export const serializeError = (error: AiSdkErrorUnion): SerializedError => {
? serializeInvalidToolInputError(error.originalError)
: serializeNoSuchToolError(error.originalError)
if ('functionality' in error) serializedError.functionality = error.functionality
if ('provider' in error) serializedError.provider = error.provider
return serializedError
}

13839
yarn.lock

File diff suppressed because it is too large Load Diff