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 # See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md
github_token: ${{ secrets.TOKEN_GITHUB_WRITE }} github_token: ${{ secrets.TOKEN_GITHUB_WRITE }}
allowed_non_write_users: "*" 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/*)" 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: | prompt: |
你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件: 你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件:
@ -108,3 +108,5 @@ jobs:
使用以下命令获取完整信息: 使用以下命令获取完整信息:
gh issue view ${{ github.event.issue.number }} --json title,body,comments 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 artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo: releaseInfo:
releaseNotes: | releaseNotes: |
What's New in v1.6.3 <!--LANG:en-->
What's New in v1.7.0-beta.1
Features: Major Features:
- Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming - 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
- UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow - Agent Management: Create, configure, and manage agents with custom settings including model selection, tool permissions, accessible paths, and MCP server integrations
- Editor: Add read-only extension support, make TextFilePreview read-only but copyable - Agent Sessions: Dedicated session management for agent interactions with persistent message history and context tracking
- Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models - Unified UI: Streamlined interface combining Assistants and Agents tabs with improved navigation and settings management
- Code Tools: Add GitHub Copilot CLI integration
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: 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 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: Technical Updates:
- Upgrade to Electron 37.6.0 - React 19.2.0 upgrade
- Update dependencies across packages - 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", "@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", "@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", "@strongtz/win32-arm64-msvc": "^0.4.7",
"express": "^5.1.0",
"font-list": "^2.0.0", "font-list": "^2.0.0",
"graceful-fs": "^4.2.11", "graceful-fs": "^4.2.11",
"jsdom": "26.1.0", "jsdom": "26.1.0",
@ -95,6 +96,7 @@
"selection-hook": "^1.0.12", "selection-hook": "^1.0.12",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"swagger-jsdoc": "^6.2.8", "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", "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"turndown": "7.2.0" "turndown": "7.2.0"
}, },
@ -265,7 +267,6 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"express": "^5.1.0",
"express-validator": "^7.2.1", "express-validator": "^7.2.1",
"fast-diff": "^1.3.0", "fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0", "fast-xml-parser": "^5.2.0",
@ -306,8 +307,8 @@
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"playwright": "^1.52.0", "playwright": "^1.52.0",
"proxy-agent": "^6.5.0", "proxy-agent": "^6.5.0",
"react": "^19.0.0", "react": "^19.2.0",
"react-dom": "^19.0.0", "react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0", "react-error-boundary": "^6.0.0",
"react-hotkeys-hook": "^4.6.1", "react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2", "react-i18next": "^14.1.2",
@ -339,7 +340,6 @@
"string-width": "^7.2.0", "string-width": "^7.2.0",
"striptags": "^3.2.0", "striptags": "^3.2.0",
"styled-components": "^6.1.11", "styled-components": "^6.1.11",
"swagger-ui-express": "^5.0.1",
"swr": "^2.3.6", "swr": "^2.3.6",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"tar": "^7.4.3", "tar": "^7.4.3",

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { cacheService } from '@data/CacheService' import { cacheService } from '@data/CacheService'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { reduxService } from '@main/services/ReduxService' 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') const logger = loggerService.withContext('ApiServerUtils')
@ -114,6 +114,7 @@ export async function validateModelId(model: string): Promise<{
error?: ModelValidationError error?: ModelValidationError
provider?: Provider provider?: Provider
modelId?: string modelId?: string
modelEndpointType?: EndpointType
}> { }> {
try { try {
if (!model || typeof model !== 'string') { if (!model || typeof model !== 'string') {
@ -166,7 +167,8 @@ export async function validateModelId(model: string): Promise<{
} }
// Check if model exists in provider // 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) { if (!modelExists) {
const availableModels = provider.models?.map((m) => m.id).join(', ') || 'none' const availableModels = provider.models?.map((m) => m.id).join(', ') || 'none'
return { return {
@ -179,10 +181,13 @@ export async function validateModelId(model: string): Promise<{
} }
} }
const modelEndpointType = modelInProvider?.endpoint_type
return { return {
valid: true, valid: true,
provider, provider,
modelId modelId,
modelEndpointType
} }
} catch (error: any) { } catch (error: any) {
logger.error('Error validating model ID', { error, model }) 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, { const response = await net.fetch(uploadUrl, {
method: 'PUT', method: 'PUT',
body: fileBuffer, body: fileBuffer as unknown as BodyInit,
headers: { headers: {
'Content-Type': 'application/pdf' 'Content-Type': 'application/pdf'
} }

View File

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

View File

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

View File

@ -19,6 +19,7 @@ import type {
FileListResponse, FileListResponse,
FileMetadata, FileMetadata,
FileUploadResponse, FileUploadResponse,
GetApiServerStatusResult,
KnowledgeBaseParams, KnowledgeBaseParams,
KnowledgeItem, KnowledgeItem,
KnowledgeSearchResult, KnowledgeSearchResult,
@ -29,8 +30,11 @@ import type {
OcrProvider, OcrProvider,
OcrResult, OcrResult,
Provider, Provider,
RestartApiServerStatusResult,
S3Config, S3Config,
Shortcut, Shortcut,
StartApiServerStatusResult,
StopApiServerStatusResult,
SupportedOcrFile, SupportedOcrFile,
WebDavConfig WebDavConfig
} from '@types' } from '@types'
@ -537,6 +541,12 @@ const api = {
ipcRenderer.on(channel, listener) ipcRenderer.on(channel, listener)
return () => ipcRenderer.off(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 { loggerService } from '@logger'
import type { AISDKWebSearchResult, MCPTool, WebSearchResults } from '@renderer/types' import type { AISDKWebSearchResult, MCPTool, WebSearchResults } from '@types'
import { WebSearchSource } from '@renderer/types' import { WebSearchSource } from '@types'
import type { Chunk } from '@renderer/types/chunk' import { type Chunk, ChunkType } from '@renderer/types/chunk'
import { 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 { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types' import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
import type { TextStreamPart, ToolSet } from 'ai' import type { TextStreamPart, ToolSet } from 'ai'
@ -357,7 +358,11 @@ export class AiSdkToChunkAdapter {
case 'error': case 'error':
this.onChunk({ this.onChunk({
type: ChunkType.ERROR, type: ChunkType.ERROR,
error: chunk.error as Record<string, any> error: new ProviderSpecificError({
message: formatErrorMessage(chunk.error),
provider: 'unknown',
cause: chunk.error
})
}) })
break break

View File

@ -50,7 +50,11 @@ export const ImageGenerationMiddleware: CompletionsMiddleware =
if (!block.file) return null if (!block.file) return null
const binaryData: Uint8Array = await FileManager.readBinaryImage(block.file) const binaryData: Uint8Array = await FileManager.readBinaryImage(block.file)
const mimeType = `${block.file.type}/${block.file.ext.slice(1)}` 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[]) 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 type { LanguageModelMiddleware } from 'ai'
import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai' import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai'
import { noThinkMiddleware } from './noThinkMiddleware'
const logger = loggerService.withContext('AiSdkMiddlewareBuilder') const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
/** /**
@ -187,6 +189,14 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
// 其他provider的通用处理 // 其他provider的通用处理
break 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) // return createVertexProvider(provider)
// } // }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (isSystemProvider(provider)) { if (isSystemProvider(provider)) {
if (provider.id === 'aihubmix') { if (provider.id === 'aihubmix') {
return aihubmixProviderCreator(model, provider) return aihubmixProviderCreator(model, provider)
} }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (provider.id === 'vertexai') { if (provider.id === 'vertexai') {
return vertexAnthropicProviderCreator(model, provider) 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') if (!match) throw new Error('Invalid base64 image format')
const mimeType = match[1] const mimeType = match[1]
const byteArray = Base64.toUint8Array(match[2]) 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 })]) await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
} else if (src.startsWith('file://')) { } 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 { addAgent } = useAgents()
const { updateAgent } = useUpdateAgent() const { updateAgent } = useUpdateAgent()
// hard-coded. We only support anthropic for now. // 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 isEditing = (agent?: AgentWithTools) => agent !== undefined
const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent)) const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent))

View File

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

View File

@ -281,7 +281,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
'new-api': { 'new-api': {
id: 'new-api', id: 'new-api',
name: 'New API', name: 'New API',
type: 'openai', type: 'new-api',
apiKey: '', apiKey: '',
apiHost: 'http://localhost:3000', apiHost: 'http://localhost:3000',
anthropicApiHost: 'http://localhost:3000', anthropicApiHost: 'http://localhost:3000',
@ -1424,5 +1424,5 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
} }
export const isNewApiProvider = (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 { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr' import useSWR from 'swr'
import { useApiServer } from '../useApiServer'
import { useAgentClient } from './useAgentClient' import { useAgentClient } from './useAgentClient'
export const useAgent = (id: string | null) => { export const useAgent = (id: string | null) => {
const { t } = useTranslation()
const client = useAgentClient() const client = useAgentClient()
const key = id ? client.agentPaths.withId(id) : null const key = id ? client.agentPaths.withId(id) : null
const { apiServerConfig, apiServerRunning } = useApiServer()
const fetcher = useCallback(async () => { const fetcher = useCallback(async () => {
if (!id || id === 'fake') { if (!id || id === 'fake') {
return null 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) const result = await client.getAgent(id)
return result return result
}, [client, id]) }, [apiServerConfig.enabled, apiServerRunning, client, id, t])
const { data, error, isLoading } = useSWR(key, id ? fetcher : null) const { data, error, isLoading } = useSWR(key, id ? fetcher : null)
return { return {

View File

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

View File

@ -21,7 +21,7 @@ export const useSessions = (agentId: string) => {
async (form: CreateSessionForm) => { async (form: CreateSessionForm) => {
try { try {
const result = await client.createSession(agentId, form) const result = await client.createSession(agentId, form)
await mutate((prev) => [...(prev ?? []), result], { revalidate: false }) await mutate((prev) => [result, ...(prev ?? [])], { revalidate: false })
return result return result
} catch (error) { } catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed'))) 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." "failed": "Failed to list agents."
} }
}, },
"server": {
"error": {
"not_running": "The API server is enabled but not running properly."
}
},
"session": { "session": {
"accessible_paths": { "accessible_paths": {
"add": "Add directory", "add": "Add directory",
@ -237,6 +242,7 @@
"messages": { "messages": {
"apiKeyCopied": "API Key copied to clipboard", "apiKeyCopied": "API Key copied to clipboard",
"apiKeyRegenerated": "API Key regenerated", "apiKeyRegenerated": "API Key regenerated",
"notEnabled": "The API Server is not enabled.",
"operationFailed": "API Server operation failed: ", "operationFailed": "API Server operation failed: ",
"restartError": "Failed to restart API Server: ", "restartError": "Failed to restart API Server: ",
"restartFailed": "API Server restart failed: ", "restartFailed": "API Server restart failed: ",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { Button } from '@heroui/button' import { Button } from '@heroui/button'
import CodeViewer from '@renderer/components/CodeViewer' import CodeViewer from '@renderer/components/CodeViewer'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTimer } from '@renderer/hooks/useTimer' import { useTimer } from '@renderer/hooks/useTimer'
import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label' import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label'
import { getProviderById } from '@renderer/services/ProviderService' import { getProviderById } from '@renderer/services/ProviderService'
@ -33,7 +34,7 @@ import {
import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage' import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage'
import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error' import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error'
import { Alert as AntdAlert, Modal } from 'antd' 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 { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
@ -303,14 +304,36 @@ const BuiltinError = ({ error }: { error: SerializedError }) => {
// 作为 base渲染公共字段应当在 ErrorDetailList 中渲染 // 作为 base渲染公共字段应当在 ErrorDetailList 中渲染
const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => { const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { highlightCode } = useCodeStyle()
const [highlightedString, setHighlightedString] = useState('')
const cause = error.cause 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 ( return (
<> <>
<BuiltinError error={error} /> <BuiltinError error={error} />
{cause && ( {cause && (
<ErrorDetailItem> <ErrorDetailItem>
<ErrorDetailLabel>{t('error.cause')}:</ErrorDetailLabel> <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> </ErrorDetailItem>
)} )}
</> </>

View File

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

View File

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

View File

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

View File

@ -47,15 +47,58 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
setActiveIndex(0) setActiveIndex(0)
}, []) }, [])
const stopSearch = useCallback(() => { const ensureWebviewReady = useCallback(
const target = webviewRef.current ?? attachedWebviewRef.current (candidate: WebviewTag | null) => {
if (!target) return if (!candidate) return null
try { try {
target.stopFindInPage('clearSelection') const webContentsId = candidate.getWebContentsId?.()
} catch (error) { if (!webContentsId) {
logger.error('stopFindInPage failed', { error }) 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(() => { const closeSearch = useCallback(() => {
setIsVisible(false) setIsVisible(false)
@ -65,7 +108,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
const performSearch = useCallback( const performSearch = useCallback(
(text: string, options?: Electron.FindInPageOptions) => { (text: string, options?: Electron.FindInPageOptions) => {
const target = webviewRef.current ?? attachedWebviewRef.current const target = getUsableWebview()
if (!target) { if (!target) {
logger.debug('Skip performSearch: webview not attached') logger.debug('Skip performSearch: webview not attached')
return return
@ -82,7 +125,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
window.toast?.error(t('common.error')) window.toast?.error(t('common.error'))
} }
}, },
[resetSearchState, stopSearch, t, webviewRef] [getUsableWebview, resetSearchState, stopSearch, t]
) )
const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => { const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
@ -130,22 +173,26 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
return () => { return () => {
activeWebview.removeEventListener('found-in-page', handle) activeWebview.removeEventListener('found-in-page', handle)
if (attachedWebviewRef.current === activeWebview) { if (attachedWebviewRef.current === activeWebview) {
try { stopFindOnWebview(activeWebview)
activeWebview.stopFindInPage('clearSelection')
} catch (error) {
logger.error('stopFindInPage failed', { error })
}
attachedWebviewRef.current = null attachedWebviewRef.current = null
} }
} }
}, [activeWebview, handleFoundInPage]) }, [activeWebview, handleFoundInPage, stopFindOnWebview])
useEffect(() => { useEffect(() => {
if (!activeWebview) return if (!activeWebview) return
if (!isWebviewReady) return
const onFindShortcut = window.api?.webview?.onFindShortcut const onFindShortcut = window.api?.webview?.onFindShortcut
if (!onFindShortcut) return 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) { if (!webContentsId) {
logger.warn('WebviewSearch: missing webContentsId', { appId }) logger.warn('WebviewSearch: missing webContentsId', { appId })
return return
@ -178,7 +225,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
return () => { return () => {
unsubscribe?.() unsubscribe?.()
} }
}, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, openSearch]) }, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, isWebviewReady, openSearch])
useEffect(() => { useEffect(() => {
if (!isVisible) return 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 () => { it('closes the search overlay when escape is forwarded from the webview', async () => {
const { webview } = createWebviewMock() const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null> 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 { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings' import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider' import { useAllProviders } from '@renderer/hooks/useProvider'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService' import { translateText } from '@renderer/services/TranslateService'
import type { FileMetadata } from '@renderer/types' import type { FileMetadata } from '@renderer/types'
@ -32,6 +31,7 @@ import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings' import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard' import Artboard from './components/Artboard'
import PaintingsList from './components/PaintingsList' import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { type ConfigItem, createModeConfigs, DEFAULT_PAINTING } from './config/aihubmixConfig' import { type ConfigItem, createModeConfigs, DEFAULT_PAINTING } from './config/aihubmixConfig'
import { checkProviderEnabled } from './utils' import { checkProviderEnabled } from './utils'
@ -73,20 +73,6 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
const providers = useAllProviders() 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 [generating, setGenerating] = useCache('chat.generating')
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@ -841,21 +827,12 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
/> />
</SettingHelpLink> </SettingHelpLink>
</ProviderTitleContainer> </ProviderTitleContainer>
<ProviderSelect
<Select value={providerOptions[1].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}> provider={aihubmixProvider}
{providerOptions.map((provider) => ( options={Options}
<Select.Option value={provider.value} key={provider.value}> onChange={handleProviderChange}
<SelectOptionContainer> className={'mb-4'}
<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>
{/* 使用JSON配置渲染设置项 */} {/* 使用JSON配置渲染设置项 */}
{modeConfigs[mode].filter((item) => (item.condition ? item.condition(painting) : true)).map(renderConfigItem)} {modeConfigs[mode].filter((item) => (item.condition ? item.condition(painting) : true)).map(renderConfigItem)}
@ -1013,12 +990,6 @@ const ModeSegmentedContainer = styled.div`
padding-top: 24px; padding-top: 24px;
` `
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
// 添加新的样式组件 // 添加新的样式组件
const ProviderTitleContainer = styled.div` const ProviderTitleContainer = styled.div`
display: flex; display: flex;

View File

@ -8,7 +8,6 @@ import { isMac } from '@renderer/config/constant'
import { getProviderLogo } from '@renderer/config/providers' import { getProviderLogo } from '@renderer/config/providers'
import { usePaintings } from '@renderer/hooks/usePaintings' import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider' import { useAllProviders } from '@renderer/hooks/useProvider'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import type { FileMetadata } from '@renderer/types' import type { FileMetadata } from '@renderer/types'
import { convertToBase64, uuid } from '@renderer/utils' import { convertToBase64, uuid } from '@renderer/utils'
@ -27,6 +26,7 @@ import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard' import Artboard from './components/Artboard'
import ImageUploader from './components/ImageUploader' import ImageUploader from './components/ImageUploader'
import PaintingsList from './components/PaintingsList' import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { import {
COURSE_URL, COURSE_URL,
DEFAULT_PAINTING, DEFAULT_PAINTING,
@ -44,20 +44,6 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
const [painting, setPainting] = useState<DmxapiPainting>(dmxapi_paintings?.[0] || DEFAULT_PAINTING) const [painting, setPainting] = useState<DmxapiPainting>(dmxapi_paintings?.[0] || DEFAULT_PAINTING)
const { t } = useTranslation() const { t } = useTranslation()
const providers = useAllProviders() 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')! const dmxapiProvider = providers.find((p) => p.id === 'dmxapi')!
@ -782,9 +768,9 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
return ( return (
<Container> <Container>
<Navbar> <Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter> <NavbarCenter className="border-r-0">{t('paintings.title')}</NavbarCenter>
{isMac && ( {isMac && (
<NavbarRight style={{ justifyContent: 'flex-end' }}> <NavbarRight className="justify-end">
<Button size="sm" className="nodrag" startContent={<PlusOutlined />} onPress={createNewPainting}> <Button size="sm" className="nodrag" startContent={<PlusOutlined />} onPress={createNewPainting}>
{t('paintings.button.new.image')} {t('paintings.button.new.image')}
</Button> </Button>
@ -794,7 +780,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container"> <ContentContainer id="content-container">
<LeftContainer> <LeftContainer>
<ProviderTitleContainer> <ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle> <SettingTitle className="mb-1">{t('common.provider')}</SettingTitle>
<div> <div>
<SettingHelpLink target="_blank" href={COURSE_URL}> <SettingHelpLink target="_blank" href={COURSE_URL}>
{t('paintings.paint_course')} {t('paintings.paint_course')}
@ -802,32 +788,19 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
<SettingHelpLink target="_blank" href={TOP_UP_URL}> <SettingHelpLink target="_blank" href={TOP_UP_URL}>
{t('paintings.top_up')} {t('paintings.top_up')}
</SettingHelpLink> </SettingHelpLink>
<Avatar <ProviderLogo radius="md" src={getProviderLogo(dmxapiProvider.id)} className="ml-1" />
radius="md"
src={getProviderLogo(dmxapiProvider.id)}
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
style={{ marginLeft: 5 }}
/>
</div> </div>
</ProviderTitleContainer> </ProviderTitleContainer>
<Select value={providerOptions[3].value} onChange={handleProviderChange} style={{ marginBottom: 15 }}> <ProviderSelect
{providerOptions.map((provider) => ( provider={dmxapiProvider}
<Select.Option value={provider.value} key={provider.value}> options={Options}
<SelectOptionContainer> onChange={handleProviderChange}
<Avatar className="mb-4"
radius="md" />
src={getProviderLogo(provider.value || '')}
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
/>
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
{painting.generationMode && {painting.generationMode &&
[generationModeType.EDIT, generationModeType.MERGE].includes(painting.generationMode) && ( [generationModeType.EDIT, generationModeType.MERGE].includes(painting.generationMode) && (
<> <>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}></SettingTitle> <SettingTitle className="mt-4 mb-1"></SettingTitle>
<ImageUploader <ImageUploader
fileMap={fileMap} fileMap={fileMap}
maxImages={painting.generationMode === generationModeType.EDIT ? 1 : 3} 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> {t('common.model')} <SettingPrice>{painting.priceModel !== '0' ? painting.priceModel : ''}</SettingPrice>
</SettingTitle> </SettingTitle>
<Select <Select
value={painting.model} value={painting.model}
onChange={onSelectModel} onChange={onSelectModel}
style={{ width: '100%' }} className="w-full"
loading={isLoadingModels} loading={isLoadingModels}
placeholder={isLoadingModels ? t('common.loading') : t('paintings.select_model')}> placeholder={isLoadingModels ? t('common.loading') : t('paintings.select_model')}>
{Object.entries(modelOptions).map(([provider, models]) => { {Object.entries(modelOptions).map(([provider, models]) => {
@ -862,11 +835,11 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
})} })}
</Select> </Select>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle> <SettingTitle className="mt-4 mb-1">{t('paintings.image.size')}</SettingTitle>
<Select <Select
value={isCustomSize ? 'custom' : painting.image_size} value={isCustomSize ? 'custom' : painting.image_size}
onChange={(value) => onSelectImageSize(value)} onChange={(value) => onSelectImageSize(value)}
style={{ width: '100%' }}> className="w-full">
{(() => { {(() => {
const currentModel = allModels.find((m) => m.id === painting.model) const currentModel = allModels.find((m) => m.id === painting.model)
const modelImageSizes = currentModel?.image_sizes || [] 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 && ( {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"> <RowFlex className="items-center gap-2">
<InputNumber <InputNumber
placeholder="W" placeholder="W"
@ -922,7 +895,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
{painting.generationMode === generationModeType.GENERATION && ( {painting.generationMode === generationModeType.GENERATION && (
<> <>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}> <SettingTitle className="mt-4 mb-1">
{t('paintings.seed')} {t('paintings.seed')}
<InfoTooltip content={t('paintings.seed_desc_tip')} /> <InfoTooltip content={t('paintings.seed_desc_tip')} />
</SettingTitle> </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> <SliderContainer>
<RadioTextBox> <RadioTextBox>
{STYLE_TYPE_OPTIONS.map((ele) => ( {STYLE_TYPE_OPTIONS.map((ele) => (
@ -954,7 +927,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</RadioTextBox> </RadioTextBox>
</SliderContainer> </SliderContainer>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}> <SettingTitle className="mt-4 mb-1">
{t('paintings.auto_create_paint')} {t('paintings.auto_create_paint')}
<InfoTooltip content={t('paintings.auto_create_paint_tip')} /> <InfoTooltip content={t('paintings.auto_create_paint_tip')} />
</SettingTitle> </SettingTitle>
@ -1026,10 +999,8 @@ const ProviderTitleContainer = styled.div`
margin-bottom: 5px; margin-bottom: 5px;
` `
const SelectOptionContainer = styled.div` const ProviderLogo = styled(Avatar)`
display: flex; border: 0.5px solid var(--color-border);
align-items: center;
gap: 8px;
` `
const ContentContainer = styled.div` 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 Scrollbar from '@renderer/components/Scrollbar'
import TranslateButton from '@renderer/components/TranslateButton' import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant' 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 { LanguagesEnum } from '@renderer/config/translate'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings' import { usePaintings } from '@renderer/hooks/usePaintings'
@ -19,8 +19,7 @@ import {
getPaintingsBackgroundOptionsLabel, getPaintingsBackgroundOptionsLabel,
getPaintingsImageSizeOptionsLabel, getPaintingsImageSizeOptionsLabel,
getPaintingsModerationOptionsLabel, getPaintingsModerationOptionsLabel,
getPaintingsQualityOptionsLabel, getPaintingsQualityOptionsLabel
getProviderLabel
} from '@renderer/i18n/label' } from '@renderer/i18n/label'
import PaintingsList from '@renderer/pages/paintings/components/PaintingsList' import PaintingsList from '@renderer/pages/paintings/components/PaintingsList'
import { DEFAULT_PAINTING, MODELS, SUPPORTED_MODELS } from '@renderer/pages/paintings/config/NewApiConfig' 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 SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings' import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard' import Artboard from './components/Artboard'
import ProviderSelect from './components/ProviderSelect'
import { checkProviderEnabled } from './utils' import { checkProviderEnabled } from './utils'
const logger = loggerService.withContext('NewApiPage') const logger = loggerService.withContext('NewApiPage')
@ -56,8 +56,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
} }
}, [openai_image_generate, openai_image_edit]) }, [openai_image_generate, openai_image_edit])
const filteredPaintings = useMemo(() => newApiPaintings[mode] || [], [newApiPaintings, mode]) // moved below after newApiProvider is defined
const [painting, setPainting] = useState<PaintingAction>(filteredPaintings[0] || DEFAULT_PAINTING)
const [currentImageIndex, setCurrentImageIndex] = useState(0) const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [abortController, setAbortController] = useState<AbortController | null>(null) const [abortController, setAbortController] = useState<AbortController | null>(null)
@ -68,26 +67,21 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
const providers = useAllProviders() const providers = useAllProviders()
const providerOptions = Options.map((option) => { const location = useLocation()
const provider = providers.find((p) => p.id === option) const routeName = location.pathname.split('/').pop() || 'new-api'
if (provider) { const newApiProviders = providers.filter((p) => isNewApiProvider(p))
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const [generating, setGenerating] = useCache('chat.generating') const [generating, setGenerating] = useCache('chat.generating')
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const [autoTranslateWithSpace] = usePreference('chat.input.translate.auto_translate_with_space') const [autoTranslateWithSpace] = usePreference('chat.input.translate.auto_translate_with_space')
const spaceClickTimer = useRef<NodeJS.Timeout>(null) 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 = [ const modeOptions = [
{ label: t('paintings.mode.generate'), value: 'openai_image_generate' }, { label: t('paintings.mode.generate'), value: 'openai_image_generate' },
@ -102,7 +96,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
}, [editImageFiles]) }, [editImageFiles])
const updatePaintingState = (updates: Partial<PaintingAction>) => { const updatePaintingState = (updates: Partial<PaintingAction>) => {
const updatedPainting = { ...painting, ...updates } const updatedPainting = { ...painting, providerId: newApiProvider.id, ...updates }
setPainting(updatedPainting) setPainting(updatedPainting)
updatePainting(mode, updatedPainting) updatePainting(mode, updatedPainting)
} }
@ -138,9 +132,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
return { return {
...DEFAULT_PAINTING, ...DEFAULT_PAINTING,
model: painting.model || modelOptions[0]?.value || '', model: painting.model || modelOptions[0]?.value || '',
id: uuid() id: uuid(),
providerId: newApiProvider.id
} }
}, [modelOptions, painting.model]) }, [modelOptions, painting.model, newApiProvider.id])
const selectedModelConfig = useMemo( const selectedModelConfig = useMemo(
() => MODELS.find((m) => m.name === painting.model) || MODELS[0], () => MODELS.find((m) => m.name === painting.model) || MODELS[0],
@ -444,11 +439,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
// 处理模式切换 // 处理模式切换
const handleModeChange = (value: string) => { const handleModeChange = (value: string) => {
setMode(value as keyof PaintingsState) setMode(value as keyof PaintingsState)
if (newApiPaintings[value as keyof PaintingsState] && newApiPaintings[value as keyof PaintingsState].length > 0) { const list = (newApiPaintings[value as keyof PaintingsState] || []).filter(
setPainting(newApiPaintings[value as keyof PaintingsState][0]) (p) => p.providerId === newApiProvider.id
} else { )
setPainting(DEFAULT_PAINTING) setPainting(list[0] || { ...DEFAULT_PAINTING, providerId: newApiProvider.id })
}
} }
// 渲染配置项的函数 // 渲染配置项的函数
@ -473,8 +467,10 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
const newPainting = getNewPainting() const newPainting = getNewPainting()
addPainting(mode, newPainting) addPainting(mode, newPainting)
setPainting(newPainting) setPainting(newPainting)
} else {
setPainting(filteredPaintings[0])
} }
}, [filteredPaintings, mode, addPainting, painting, getNewPainting]) }, [filteredPaintings, mode, addPainting, getNewPainting])
useEffect(() => { useEffect(() => {
const timer = spaceClickTimer.current const timer = spaceClickTimer.current
@ -501,9 +497,11 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
<LeftContainer> <LeftContainer>
<ProviderTitleContainer> <ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle> <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')} {t('paintings.learn_more')}
<Avatar <ProviderLogo
radius="md" radius="md"
src={getProviderLogo(newApiProvider.id)} src={getProviderLogo(newApiProvider.id)}
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]" className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
@ -512,23 +510,7 @@ const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => {
</SettingHelpLink> </SettingHelpLink>
</ProviderTitleContainer> </ProviderTitleContainer>
<Select <ProviderSelect provider={newApiProvider} options={Options} onChange={handleProviderChange} />
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>
{/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */} {/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */}
{modelOptions.length === 0 && ( {modelOptions.length === 0 && (
@ -792,20 +774,16 @@ const ToolbarMenu = styled.div`
gap: 6px; gap: 6px;
` `
// 添加新的样式组件 const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border);
`
const ModeSegmentedContainer = styled.div` const ModeSegmentedContainer = styled.div`
display: flex; display: flex;
justify-content: center; justify-content: center;
padding-top: 24px; padding-top: 24px;
` `
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
// 添加新的样式组件
const ProviderTitleContainer = styled.div` const ProviderTitleContainer = styled.div`
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

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

View File

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

View File

@ -11,7 +11,6 @@ import { getProviderLogo } from '@renderer/config/providers'
import { LanguagesEnum } from '@renderer/config/translate' import { LanguagesEnum } from '@renderer/config/translate'
import { usePaintings } from '@renderer/hooks/usePaintings' import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider' import { useAllProviders } from '@renderer/hooks/useProvider'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService' import { translateText } from '@renderer/services/TranslateService'
import type { TokenFluxPainting } from '@renderer/types' import type { TokenFluxPainting } from '@renderer/types'
@ -29,6 +28,7 @@ import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard' import Artboard from './components/Artboard'
import { DynamicFormRender } from './components/DynamicFormRender' import { DynamicFormRender } from './components/DynamicFormRender'
import PaintingsList from './components/PaintingsList' import PaintingsList from './components/PaintingsList'
import ProviderSelect from './components/ProviderSelect'
import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig' import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig'
import { checkProviderEnabled } from './utils' import { checkProviderEnabled } from './utils'
import TokenFluxService from './utils/TokenFluxService' import TokenFluxService from './utils/TokenFluxService'
@ -54,21 +54,6 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
tokenFluxPaintings[0] || { ...DEFAULT_TOKENFLUX_PAINTING, id: uuid() } 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 navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [autoTranslateWithSpace] = usePreference('chat.input.translate.auto_translate_with_space') const [autoTranslateWithSpace] = usePreference('chat.input.translate.auto_translate_with_space')
@ -388,23 +373,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
</SettingHelpLink> </SettingHelpLink>
</ProviderTitleContainer> </ProviderTitleContainer>
<Select <ProviderSelect provider={tokenfluxProvider} options={Options} onChange={handleProviderChange} />
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>
{/* Model & Pricing Section */} {/* Model & Pricing Section */}
<SectionTitle <SectionTitle
@ -759,11 +728,4 @@ const ProviderTitleContainer = styled.div`
align-items: center; align-items: center;
margin-bottom: 5px; margin-bottom: 5px;
` `
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
export default TokenFluxPage export default TokenFluxPage

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import { ProviderTypeSchema } from './provider'
// Request schema for /v1/models // Request schema for /v1/models
export const ApiModelsFilterSchema = z.object({ export const ApiModelsFilterSchema = z.object({
providerType: ProviderTypeSchema.optional(), providerType: ProviderTypeSchema.optional(),
supportAnthropic: z.coerce.boolean().optional(),
offset: z.coerce.number().min(0).default(0).optional(), offset: z.coerce.number().min(0).default(0).optional(),
limit: z.coerce.number().min(1).default(20).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 UnsupportedFunctionalityError
} from 'ai' } from 'ai'
import type { ProviderSpecificError } from './provider-specific-error'
import type { Serializable } from './serialize' import type { Serializable } from './serialize'
export interface SerializedError { export interface SerializedError {
@ -80,7 +81,7 @@ export interface SerializedAiSdkInvalidArgumentError extends SerializedAiSdkErro
export const isSerializedAiSdkInvalidArgumentError = ( export const isSerializedAiSdkInvalidArgumentError = (
error: SerializedError error: SerializedError
): error is SerializedAiSdkInvalidArgumentError => { ): 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 { export interface SerializedAiSdkInvalidDataContentError extends SerializedAiSdkError {
@ -198,10 +199,20 @@ export interface SerializedAiSdkNoSuchToolError extends SerializedAiSdkError {
readonly availableTools: string[] | null readonly availableTools: string[] | null
} }
export interface SerializedAiSdkProviderSpecificError extends SerializedAiSdkError {
readonly provider: string
}
export const isSerializedAiSdkNoSuchToolError = (error: SerializedError): error is SerializedAiSdkNoSuchToolError => { export const isSerializedAiSdkNoSuchToolError = (error: SerializedError): error is SerializedAiSdkNoSuchToolError => {
return isSerializedAiSdkError(error) && 'toolName' in error && 'availableTools' in error 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 { export interface SerializedAiSdkRetryError extends SerializedAiSdkError {
readonly reason: string readonly reason: string
readonly lastError: Serializable readonly lastError: Serializable
@ -277,6 +288,7 @@ export type AiSdkErrorUnion =
| NoSuchModelError | NoSuchModelError
| NoSuchProviderError | NoSuchProviderError
| NoSuchToolError | NoSuchToolError
| ProviderSpecificError
| RetryError | RetryError
| ToolCallRepairError | ToolCallRepairError
| TypeValidationError | TypeValidationError
@ -297,6 +309,7 @@ export type SerializedAiSdkErrorUnion =
| SerializedAiSdkNoSuchModelError | SerializedAiSdkNoSuchModelError
| SerializedAiSdkNoSuchProviderError | SerializedAiSdkNoSuchProviderError
| SerializedAiSdkNoSuchToolError | SerializedAiSdkNoSuchToolError
| SerializedAiSdkProviderSpecificError
| SerializedAiSdkRetryError | SerializedAiSdkRetryError
| SerializedAiSdkToolCallRepairError | SerializedAiSdkToolCallRepairError
| SerializedAiSdkTypeValidationError | SerializedAiSdkTypeValidationError
@ -317,6 +330,7 @@ export const isSerializedAiSdkErrorUnion = (error: SerializedError): error is Se
isSerializedAiSdkNoSuchModelError(error) || isSerializedAiSdkNoSuchModelError(error) ||
isSerializedAiSdkNoSuchProviderError(error) || isSerializedAiSdkNoSuchProviderError(error) ||
isSerializedAiSdkNoSuchToolError(error) || isSerializedAiSdkNoSuchToolError(error) ||
isSerializedAiSdkProviderSpecificError(error) ||
isSerializedAiSdkRetryError(error) || isSerializedAiSdkRetryError(error) ||
isSerializedAiSdkToolCallRepairError(error) || isSerializedAiSdkToolCallRepairError(error) ||
isSerializedAiSdkTypeValidationError(error) || isSerializedAiSdkTypeValidationError(error) ||

View File

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

View File

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

View File

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

13839
yarn.lock

File diff suppressed because it is too large Load Diff