feat: integrate @cherry-studio/ai-core and enhance AI SDK support

- Added @cherry-studio/ai-core as a workspace dependency in package.json for improved modularity.
- Updated tsconfig to include paths for the new AI core package, enhancing type resolution.
- Refactored aiCore package to use source files directly, improving build efficiency.
- Introduced a new AiSdkToChunkAdapter for converting AI SDK streams to Cherry Studio chunk format.
- Implemented a modernized AI provider interface in index_new.ts, allowing fallback to legacy implementations.
- Enhanced parameter transformation logic for better integration with AI SDK features.
- Updated ApiService to utilize the new AI provider, streamlining chat completion requests.
This commit is contained in:
MyPrototypeWhat
2025-06-19 18:55:59 +08:00
parent 1c5a30cf49
commit 43d55b7e45
11 changed files with 856 additions and 47 deletions
@@ -0,0 +1,296 @@
/**
* AI SDK 到 Cherry Studio Chunk 适配器
* 用于将 AI SDK 的 fullStream 转换为 Cherry Studio 的 chunk 格式
*/
import { TextStreamPart } from '@cherry-studio/ai-core'
import { Chunk, ChunkType } from '@renderer/types/chunk'
export interface CherryStudioChunk {
type: 'text-delta' | 'text-complete' | 'tool-call' | 'tool-result' | 'finish' | 'error'
text?: string
toolCall?: any
toolResult?: any
finishReason?: string
usage?: any
error?: any
}
/**
* AI SDK 到 Cherry Studio Chunk 适配器类
* 处理 fullStream 到 Cherry Studio chunk 的转换
*/
export class AiSdkToChunkAdapter {
constructor(private onChunk: (chunk: Chunk) => void) {}
/**
* 处理 AI SDK 流结果
* @param aiSdkResult AI SDK 的流结果对象
* @returns 最终的文本内容
*/
async processStream(aiSdkResult: any): Promise<string> {
// 如果是流式且有 fullStream
if (aiSdkResult.fullStream) {
await this.readFullStream(aiSdkResult.fullStream)
}
// 使用 streamResult.text 获取最终结果
return await aiSdkResult.text
}
/**
* 读取 fullStream 并转换为 Cherry Studio chunks
* @param fullStream AI SDK 的 fullStream (ReadableStream)
*/
private async readFullStream(fullStream: ReadableStream<TextStreamPart<any>>) {
const reader = fullStream.getReader()
const final = {
text: '',
reasoning_content: ''
}
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
// 转换并发送 chunk
this.convertAndEmitChunk(value, final)
}
} finally {
reader.releaseLock()
}
}
/**
* 转换 AI SDK chunk 为 Cherry Studio chunk 并调用回调
* @param chunk AI SDK 的 chunk 数据
*/
private convertAndEmitChunk(chunk: TextStreamPart<any>, final: { text: string; reasoning_content: string }) {
console.log('AI SDK chunk type:', chunk.type, chunk)
switch (chunk.type) {
// === 文本相关事件 ===
case 'text-delta':
final.text += chunk.textDelta || ''
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: chunk.textDelta || ''
})
if (final.reasoning_content) {
this.onChunk({
type: ChunkType.THINKING_COMPLETE,
text: final.reasoning_content || ''
})
final.reasoning_content = ''
}
break
// === 推理相关事件 ===
case 'reasoning':
final.reasoning_content += chunk.textDelta || ''
this.onChunk({
type: ChunkType.THINKING_DELTA,
text: chunk.textDelta || ''
})
break
case 'reasoning-signature':
// 推理签名,可以映射到思考完成
this.onChunk({
type: ChunkType.THINKING_COMPLETE,
text: chunk.signature || ''
})
break
case 'redacted-reasoning':
// 被编辑的推理内容,也映射到思考
this.onChunk({
type: ChunkType.THINKING_DELTA,
text: chunk.data || ''
})
break
// === 工具调用相关事件 ===
case 'tool-call-streaming-start':
// 开始流式工具调用
this.onChunk({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: [
{
id: chunk.toolCallId,
name: chunk.toolName,
args: {}
}
]
})
break
case 'tool-call-delta':
// 工具调用参数的增量更新
this.onChunk({
type: ChunkType.MCP_TOOL_IN_PROGRESS,
responses: [
{
id: chunk.toolCallId,
tool: {
id: chunk.toolName,
// TODO: serverId,serverName
serverId: 'ai-sdk',
serverName: 'AI SDK',
name: chunk.toolName,
description: '',
inputSchema: {
type: 'object',
title: chunk.toolName,
properties: {}
}
},
arguments: {},
status: 'invoking',
response: chunk.argsTextDelta,
toolCallId: chunk.toolCallId
}
]
})
break
case 'tool-call':
// 完整的工具调用
this.onChunk({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: [
{
id: chunk.toolCallId,
name: chunk.toolName,
args: chunk.args
}
]
})
break
case 'tool-result':
// 工具调用结果
this.onChunk({
type: ChunkType.MCP_TOOL_COMPLETE,
responses: [
{
id: chunk.toolCallId,
tool: {
id: chunk.toolName,
// TODO: serverId,serverName
serverId: 'ai-sdk',
serverName: 'AI SDK',
name: chunk.toolName,
description: '',
inputSchema: {
type: 'object',
title: chunk.toolName,
properties: {}
}
},
arguments: chunk.args || {},
status: 'done',
response: chunk.result,
toolCallId: chunk.toolCallId
}
]
})
break
// === 步骤相关事件 ===
// case 'step-start':
// this.onChunk({
// type: ChunkType.LLM_RESPONSE_CREATED
// })
// break
case 'step-finish':
this.onChunk({
type: ChunkType.BLOCK_COMPLETE,
response: {
text: final.text || '',
reasoning_content: final.reasoning_content || '',
usage: {
completion_tokens: chunk.usage.completionTokens || 0,
prompt_tokens: chunk.usage.promptTokens || 0,
total_tokens: chunk.usage.totalTokens || 0
},
metrics: chunk.usage
? {
completion_tokens: chunk.usage.completionTokens || 0,
time_completion_millsec: 0
}
: undefined
}
})
break
case 'finish':
this.onChunk({
type: ChunkType.TEXT_COMPLETE,
text: final.text || '' // TEXT_COMPLETE 需要 text 字段
})
this.onChunk({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
text: final.text || '',
reasoning_content: final.reasoning_content || '',
usage: {
completion_tokens: chunk.usage.completionTokens || 0,
prompt_tokens: chunk.usage.promptTokens || 0,
total_tokens: chunk.usage.totalTokens || 0
},
metrics: chunk.usage
? {
completion_tokens: chunk.usage.completionTokens || 0,
time_completion_millsec: 0
}
: undefined
}
})
break
// === 源和文件相关事件 ===
case 'source':
// 源信息,可以映射到知识搜索完成
this.onChunk({
type: ChunkType.KNOWLEDGE_SEARCH_COMPLETE,
knowledge: [
{
id: Number(chunk.source.id) || Date.now(),
content: chunk.source.title || '',
sourceUrl: chunk.source.url || '',
type: 'url'
}
]
})
break
case 'file':
// 文件相关事件,可能是图片生成
this.onChunk({
type: ChunkType.IMAGE_COMPLETE,
image: {
type: 'base64',
images: [chunk.base64]
}
})
break
case 'error':
this.onChunk({
type: ChunkType.ERROR,
error: {
message: chunk.error || 'Unknown error'
}
})
break
default:
// 其他类型的 chunk 可以忽略或记录日志
console.log('Unhandled AI SDK chunk type:', chunk.type, chunk)
}
}
}
export default AiSdkToChunkAdapter
+230
View File
@@ -0,0 +1,230 @@
/**
* Cherry Studio AI Core - 新版本入口
* 集成 @cherry-studio/ai-core 库的渐进式重构方案
*
* 融合方案:简化实现,专注于核心功能
* 1. 优先使用新AI SDK
* 2. 失败时fallback到原有实现
* 3. 暂时保持接口兼容性
*/
import {
AiClient,
AiCore,
createClient,
type OpenAICompatibleProviderSettings,
type ProviderId
} from '@cherry-studio/ai-core'
import { isDedicatedImageGenerationModel } from '@renderer/config/models'
import type { GenerateImageParams, Model, Provider } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import { RequestOptions } from '@renderer/types/sdk'
// 引入适配器
import AiSdkToChunkAdapter from './AiSdkToChunkAdapter'
// 引入原有的AiProvider作为fallback
import LegacyAiProvider from './index'
import { CompletionsParams, CompletionsResult } from './middleware/schemas'
// 引入参数转换模块
import { buildStreamTextParams } from './transformParameters'
/**
* 将现有 Provider 类型映射到 AI SDK 的 Provider ID
* 根据 registry.ts 中的支持列表进行映射
*/
function mapProviderTypeToAiSdkId(providerType: string): string {
// Cherry Studio Provider Type -> AI SDK Provider ID 映射表
const typeMapping: Record<string, string> = {
// 需要转换的映射
grok: 'xai', // grok -> xai
'azure-openai': 'azure', // azure-openai -> azure
gemini: 'google' // gemini -> google
}
return typeMapping[providerType]
}
/**
* 将 Provider 配置转换为新 AI SDK 格式
*/
function providerToAiSdkConfig(provider: Provider): {
providerId: ProviderId | 'openai-compatible'
options: any
} {
console.log('provider', provider)
// 1. 先映射 provider 类型到 AI SDK ID
const mappedProviderId = mapProviderTypeToAiSdkId(provider.id)
// 2. 检查映射后的 provider ID 是否在 AI SDK 注册表中
const isSupported = AiCore.isSupported(mappedProviderId)
console.log(`Provider mapping: ${provider.type} -> ${mappedProviderId}, supported: ${isSupported}`)
// 3. 如果映射的 provider 不支持,则使用 openai-compatible
if (isSupported) {
return {
providerId: mappedProviderId as ProviderId,
options: {
apiKey: provider.apiKey
}
}
} else {
console.log(`Using openai-compatible fallback for provider: ${provider.type}`)
const compatibleConfig: OpenAICompatibleProviderSettings = {
name: provider.name || provider.type,
apiKey: provider.apiKey,
baseURL: provider.apiHost
}
return {
providerId: 'openai-compatible',
options: compatibleConfig
}
}
}
/**
* 检查是否支持使用新的AI SDK
*/
function isModernSdkSupported(provider: Provider, model?: Model): boolean {
// 目前支持主要的providers
const supportedProviders = ['openai', 'anthropic', 'gemini', 'azure-openai']
// 检查provider类型
if (!supportedProviders.includes(provider.type)) {
return false
}
// 检查是否为图像生成模型(暂时不支持)
if (model && isDedicatedImageGenerationModel(model)) {
return false
}
return true
}
export default class ModernAiProvider {
private modernClient?: AiClient
private legacyProvider: LegacyAiProvider
private provider: Provider
constructor(provider: Provider) {
this.provider = provider
this.legacyProvider = new LegacyAiProvider(provider)
const config = providerToAiSdkConfig(provider)
this.modernClient = createClient(config.providerId, config.options)
}
public async completions(params: CompletionsParams, options?: RequestOptions): Promise<CompletionsResult> {
// const model = params.assistant.model
// 检查是否应该使用现代化客户端
// if (this.modernClient && model && isModernSdkSupported(this.provider, model)) {
// try {
return await this.modernCompletions(params, options)
// } catch (error) {
// console.warn('Modern client failed, falling back to legacy:', error)
// fallback到原有实现
// }
// }
// 使用原有实现
// return this.legacyProvider.completions(params, options)
}
/**
* 使用现代化AI SDK的completions实现
* 使用 AiSdkUtils 工具模块进行参数构建
*/
private async modernCompletions(params: CompletionsParams, options?: RequestOptions): Promise<CompletionsResult> {
if (!this.modernClient || !params.assistant.model) {
throw new Error('Modern client not available')
}
console.log('Modern completions with params:', params, 'options:', options)
const model = params.assistant.model
const assistant = params.assistant
// 检查 messages 类型并转换
const messages = Array.isArray(params.messages) ? params.messages : []
if (typeof params.messages === 'string') {
console.warn('Messages is string, using empty array')
}
// 使用 transformParameters 模块构建参数
const aiSdkParams = await buildStreamTextParams(messages, assistant, model, {
maxTokens: params.maxTokens,
mcpTools: params.mcpTools
})
console.log('Built AI SDK params:', aiSdkParams)
const chunks: Chunk[] = []
try {
if (params.streamOutput && params.onChunk) {
// 流式处理 - 使用适配器
const adapter = new AiSdkToChunkAdapter(params.onChunk)
const streamResult = await this.modernClient.streamText(model.id, aiSdkParams)
const finalText = await adapter.processStream(streamResult)
return {
getText: () => finalText
}
} else if (params.streamOutput) {
// 流式处理但没有 onChunk 回调
const streamResult = await this.modernClient.streamText(model.id, aiSdkParams)
const finalText = await streamResult.text
return {
getText: () => finalText
}
} else {
// 非流式处理
const result = await this.modernClient.generateText(model.id, aiSdkParams)
const cherryChunk: Chunk = {
type: ChunkType.TEXT_COMPLETE,
text: result.text || ''
}
chunks.push(cherryChunk)
if (params.onChunk) {
params.onChunk(cherryChunk)
}
return {
getText: () => result.text || ''
}
}
} catch (error) {
console.error('Modern AI SDK error:', error)
throw error
}
}
// 代理其他方法到原有实现
public async models() {
return this.legacyProvider.models()
}
public async getEmbeddingDimensions(model: Model): Promise<number> {
return this.legacyProvider.getEmbeddingDimensions(model)
}
public async generateImage(params: GenerateImageParams): Promise<string[]> {
return this.legacyProvider.generateImage(params)
}
public getBaseURL(): string {
return this.legacyProvider.getBaseURL()
}
public getApiKey(): string {
return this.legacyProvider.getApiKey()
}
}
// 为了方便调试,导出一些工具函数
export { isModernSdkSupported, providerToAiSdkConfig }
@@ -0,0 +1,269 @@
/**
* AI SDK 参数转换模块
* 统一管理从各个 apiClient 提取的参数处理和转换功能
*/
import type { StreamTextParams } from '@cherry-studio/ai-core'
import { isNotSupportTemperatureAndTopP, isSupportedFlexServiceTier } from '@renderer/config/models'
import type { Assistant, MCPTool, Message, Model } from '@renderer/types'
import { FileTypes } from '@renderer/types'
import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { defaultTimeout } from '@shared/config/constant'
/**
* 获取温度参数
*/
export function getTemperature(assistant: Assistant, model: Model): number | undefined {
return isNotSupportTemperatureAndTopP(model) ? undefined : assistant.settings?.temperature
}
/**
* 获取 TopP 参数
*/
export function getTopP(assistant: Assistant, model: Model): number | undefined {
return isNotSupportTemperatureAndTopP(model) ? undefined : assistant.settings?.topP
}
/**
* 获取超时设置
*/
export function getTimeout(model: Model): number {
if (isSupportedFlexServiceTier(model)) {
return 15 * 1000 * 60
}
return defaultTimeout
}
/**
* 构建系统提示词
*/
export async function buildSystemPromptWithTools(
prompt: string,
mcpTools?: MCPTool[],
assistant?: Assistant
): Promise<string> {
return await buildSystemPrompt(prompt, mcpTools, assistant)
}
// /**
// * 转换 MCP 工具为 AI SDK 工具格式
// * 注意:这里返回通用格式,实际使用时需要根据具体 provider 转换
// TODO: 需要使用ai-sdk的mcp
// */
// export function convertMcpToolsToSdkTools(mcpTools: MCPTool[]): Pick<StreamTextParams, 'tools'> {
// return mcpTools.map((tool) => ({
// type: 'function',
// function: {
// name: tool.id,
// description: tool.description,
// parameters: tool.inputSchema || {}
// }
// }))
// }
/**
* 提取文件内容
*/
export async function extractFileContent(message: Message): Promise<string> {
const fileBlocks = findFileBlocks(message)
if (fileBlocks.length > 0) {
const textFileBlocks = fileBlocks.filter(
(fb) => fb.file && [FileTypes.TEXT, FileTypes.DOCUMENT].includes(fb.file.type)
)
if (textFileBlocks.length > 0) {
let text = ''
const divider = '\n\n---\n\n'
for (const fileBlock of textFileBlocks) {
const file = fileBlock.file
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
const fileNameRow = 'file: ' + file.origin_name + '\n\n'
text = text + fileNameRow + fileContent + divider
}
return text
}
}
return ''
}
/**
* 转换消息为 AI SDK 参数格式
* 基于 OpenAI 格式的通用转换,支持文本、图片和文件
*/
export async function convertMessageToSdkParam(message: Message, isVisionModel = false): Promise<any> {
const content = getMainTextContent(message)
const fileBlocks = findFileBlocks(message)
const imageBlocks = findImageBlocks(message)
// 简单消息(无文件无图片)
if (fileBlocks.length === 0 && imageBlocks.length === 0) {
return {
role: message.role === 'system' ? 'user' : message.role,
content
}
}
// 复杂消息(包含文件或图片)
const parts: any[] = []
if (content) {
parts.push({ type: 'text', text: content })
}
// 处理图片(仅在支持视觉的模型中)
if (isVisionModel) {
for (const imageBlock of imageBlocks) {
if (imageBlock.file) {
try {
const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext)
parts.push({
type: 'image_url',
image_url: { url: image.data }
})
} catch (error) {
console.warn('Failed to load image:', error)
}
} else if (imageBlock.url && imageBlock.url.startsWith('data:')) {
parts.push({
type: 'image_url',
image_url: { url: imageBlock.url }
})
}
}
}
// 处理文件
for (const fileBlock of fileBlocks) {
const file = fileBlock.file
if (!file) continue
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
try {
const fileContent = await window.api.file.read(file.id + file.ext)
parts.push({
type: 'text',
text: `${file.origin_name}\n${fileContent.trim()}`
})
} catch (error) {
console.warn('Failed to read file:', error)
}
}
}
return {
role: message.role === 'system' ? 'user' : message.role,
content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text : parts
}
}
/**
* 转换 Cherry Studio 消息数组为 AI SDK 消息数组
*/
export async function convertMessagesToSdkMessages(
messages: Message[],
model: Model
): Promise<StreamTextParams['messages']> {
const sdkMessages: StreamTextParams['messages'] = []
const isVision = model.id.includes('vision') || model.id.includes('gpt-4') // 简单的视觉模型检测
for (const message of messages) {
const sdkMessage = await convertMessageToSdkParam(message, isVision)
sdkMessages.push(sdkMessage)
}
return sdkMessages
}
/**
* 构建 AI SDK 流式参数
* 这是主要的参数构建函数,整合所有转换逻辑
*/
export async function buildStreamTextParams(
messages: Message[],
assistant: Assistant,
model: Model,
options: {
maxTokens?: number
mcpTools?: MCPTool[]
enableTools?: boolean
} = {}
): Promise<StreamTextParams> {
const { maxTokens, mcpTools, enableTools = false } = options
// 转换消息
const sdkMessages = await convertMessagesToSdkMessages(messages, model)
// 构建系统提示
let systemPrompt = assistant.prompt || ''
if (mcpTools && mcpTools.length > 0) {
systemPrompt = await buildSystemPromptWithTools(systemPrompt, mcpTools, assistant)
}
// 构建基础参数
const params: StreamTextParams = {
messages: sdkMessages,
maxTokens: maxTokens || 1000,
temperature: getTemperature(assistant, model),
topP: getTopP(assistant, model),
system: systemPrompt || undefined,
...getCustomParameters(assistant)
}
// 添加工具(如果启用且有工具)
if (enableTools && mcpTools && mcpTools.length > 0) {
// TODO: 暂时注释掉工具支持,等类型问题解决后再启用
// params.tools = convertMcpToolsToSdkTools(mcpTools)
}
return params
}
/**
* 构建非流式的 generateText 参数
*/
export async function buildGenerateTextParams(
messages: Message[],
assistant: Assistant,
model: Model,
options: {
maxTokens?: number
mcpTools?: MCPTool[]
enableTools?: boolean
} = {}
): Promise<any> {
// 复用流式参数的构建逻辑
return await buildStreamTextParams(messages, assistant, model, options)
}
/**
* 获取自定义参数
* 从 assistant 设置中提取自定义参数
*/
export function getCustomParameters(assistant: Assistant): Record<string, any> {
return (
assistant?.settings?.customParameters?.reduce((acc, param) => {
if (!param.name?.trim()) {
return acc
}
if (param.type === 'json') {
const value = param.value as string
if (value === 'undefined') {
return { ...acc, [param.name]: undefined }
}
try {
return { ...acc, [param.name]: JSON.parse(value) }
} catch {
return { ...acc, [param.name]: value }
}
}
return {
...acc,
[param.name]: param.value
}
}, {}) || {}
)
}
+2 -2
View File
@@ -37,7 +37,7 @@ import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils
import { findLast, isEmpty, takeRight } from 'lodash'
import AiProvider from '../aiCore'
import store from '../store'
import AiProviderNew from '../aiCore/index_new'
import {
getAssistantProvider,
getAssistantSettings,
@@ -313,7 +313,7 @@ export async function fetchChatCompletion({
console.log('fetchChatCompletion', messages, assistant)
const provider = getAssistantProvider(assistant)
const AI = new AiProvider(provider)
const AI = new AiProviderNew(provider)
// Make sure that 'Clear Context' works for all scenarios including external tool and normal chat.
messages = filterContextMessages(messages)