mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-09 14:59:27 +08:00
feat(aiCore): enhance AI SDK with tracing and telemetry support
- Integrated tracing capabilities into the ModernAiProvider, allowing for better tracking of AI completions and image generation processes. - Added a new TelemetryPlugin to inject telemetry data into AI SDK requests, ensuring compatibility with existing tracing systems. - Updated middleware and plugin configurations to support topic-based tracing, improving the overall observability of AI interactions. - Introduced comprehensive logging throughout the AI SDK processes to facilitate debugging and performance monitoring. - Added unit tests for new functionalities to ensure reliability and maintainability.
This commit is contained in:
parent
ff7ad52ad5
commit
cb55f7a69b
@ -9,8 +9,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createExecutor, generateImage, StreamTextParams } from '@cherrystudio/ai-core'
|
import { createExecutor, generateImage, StreamTextParams } from '@cherrystudio/ai-core'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
import { isNotSupportedImageSizeModel } from '@renderer/config/models'
|
import { isNotSupportedImageSizeModel } from '@renderer/config/models'
|
||||||
import type { GenerateImageParams, Model, Provider } from '@renderer/types'
|
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
|
||||||
|
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
||||||
|
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
|
||||||
import { ChunkType } from '@renderer/types/chunk'
|
import { ChunkType } from '@renderer/types/chunk'
|
||||||
|
|
||||||
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
|
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
|
||||||
@ -20,6 +23,8 @@ import { AiSdkMiddlewareConfig, buildAiSdkMiddlewares } from './middleware/AiSdk
|
|||||||
import { buildPlugins } from './plugins/PluginBuilder'
|
import { buildPlugins } from './plugins/PluginBuilder'
|
||||||
import { getActualProvider, isModernSdkSupported, providerToAiSdkConfig } from './provider/ProviderConfigProcessor'
|
import { getActualProvider, isModernSdkSupported, providerToAiSdkConfig } from './provider/ProviderConfigProcessor'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('ModernAiProvider')
|
||||||
|
|
||||||
export default class ModernAiProvider {
|
export default class ModernAiProvider {
|
||||||
private legacyProvider: LegacyAiProvider
|
private legacyProvider: LegacyAiProvider
|
||||||
private config: ReturnType<typeof providerToAiSdkConfig>
|
private config: ReturnType<typeof providerToAiSdkConfig>
|
||||||
@ -40,15 +45,112 @@ export default class ModernAiProvider {
|
|||||||
public async completions(
|
public async completions(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
params: StreamTextParams,
|
params: StreamTextParams,
|
||||||
middlewareConfig: AiSdkMiddlewareConfig
|
config: AiSdkMiddlewareConfig & {
|
||||||
|
assistant: Assistant
|
||||||
|
// topicId for tracing
|
||||||
|
topicId?: string
|
||||||
|
callType: string
|
||||||
|
}
|
||||||
): Promise<CompletionsResult> {
|
): Promise<CompletionsResult> {
|
||||||
console.log('completions', modelId, params, middlewareConfig)
|
if (config.isImageGenerationEndpoint) {
|
||||||
|
return await this.modernImageGeneration(modelId, params, config)
|
||||||
if (middlewareConfig.isImageGenerationEndpoint) {
|
|
||||||
return await this.modernImageGeneration(modelId, params, middlewareConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.modernCompletions(modelId, params, middlewareConfig)
|
return await this.modernCompletions(modelId, params, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带trace支持的completions方法
|
||||||
|
* 类似于legacy的completionsForTrace,确保AI SDK spans在正确的trace上下文中
|
||||||
|
*/
|
||||||
|
public async completionsForTrace(
|
||||||
|
modelId: string,
|
||||||
|
params: StreamTextParams,
|
||||||
|
config: AiSdkMiddlewareConfig & {
|
||||||
|
assistant: Assistant
|
||||||
|
// topicId for tracing
|
||||||
|
topicId?: string
|
||||||
|
callType: string
|
||||||
|
}
|
||||||
|
): Promise<CompletionsResult> {
|
||||||
|
if (!config.topicId) {
|
||||||
|
logger.warn('No topicId provided, falling back to regular completions')
|
||||||
|
return await this.completions(modelId, params, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
const traceName = `${this.actualProvider.name}.${modelId}.${config.callType}`
|
||||||
|
const traceParams: StartSpanParams = {
|
||||||
|
name: traceName,
|
||||||
|
tag: 'LLM',
|
||||||
|
topicId: config.topicId,
|
||||||
|
modelName: config.assistant.model?.name, // 使用modelId而不是provider名称
|
||||||
|
inputs: params
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Starting AI SDK trace span', {
|
||||||
|
traceName,
|
||||||
|
topicId: config.topicId,
|
||||||
|
modelId,
|
||||||
|
hasTools: !!params.tools && Object.keys(params.tools).length > 0,
|
||||||
|
toolNames: params.tools ? Object.keys(params.tools) : [],
|
||||||
|
isImageGeneration: config.isImageGenerationEndpoint
|
||||||
|
})
|
||||||
|
|
||||||
|
const span = addSpan(traceParams)
|
||||||
|
if (!span) {
|
||||||
|
logger.warn('Failed to create span, falling back to regular completions', {
|
||||||
|
topicId: config.topicId,
|
||||||
|
modelId,
|
||||||
|
traceName
|
||||||
|
})
|
||||||
|
return await this.completions(modelId, params, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('Created parent span, now calling completions', {
|
||||||
|
spanId: span.spanContext().spanId,
|
||||||
|
traceId: span.spanContext().traceId,
|
||||||
|
topicId: config.topicId,
|
||||||
|
modelId,
|
||||||
|
parentSpanCreated: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await this.completions(modelId, params, config)
|
||||||
|
|
||||||
|
logger.info('Completions finished, ending parent span', {
|
||||||
|
spanId: span.spanContext().spanId,
|
||||||
|
traceId: span.spanContext().traceId,
|
||||||
|
topicId: config.topicId,
|
||||||
|
modelId,
|
||||||
|
resultLength: result.getText().length
|
||||||
|
})
|
||||||
|
|
||||||
|
// 标记span完成
|
||||||
|
endSpan({
|
||||||
|
topicId: config.topicId,
|
||||||
|
outputs: result,
|
||||||
|
span,
|
||||||
|
modelName: modelId // 使用modelId保持一致性
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in completionsForTrace, ending parent span with error', error as Error, {
|
||||||
|
spanId: span.spanContext().spanId,
|
||||||
|
traceId: span.spanContext().traceId,
|
||||||
|
topicId: config.topicId,
|
||||||
|
modelId
|
||||||
|
})
|
||||||
|
|
||||||
|
// 标记span出错
|
||||||
|
endSpan({
|
||||||
|
topicId: config.topicId,
|
||||||
|
error: error as Error,
|
||||||
|
span,
|
||||||
|
modelName: modelId // 使用modelId保持一致性
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -57,46 +159,123 @@ export default class ModernAiProvider {
|
|||||||
private async modernCompletions(
|
private async modernCompletions(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
params: StreamTextParams,
|
params: StreamTextParams,
|
||||||
middlewareConfig: AiSdkMiddlewareConfig
|
config: AiSdkMiddlewareConfig & {
|
||||||
|
assistant: Assistant
|
||||||
|
// topicId for tracing
|
||||||
|
topicId?: string
|
||||||
|
callType: string
|
||||||
|
}
|
||||||
): Promise<CompletionsResult> {
|
): Promise<CompletionsResult> {
|
||||||
// try {
|
logger.info('Starting modernCompletions', {
|
||||||
|
modelId,
|
||||||
|
providerId: this.config.providerId,
|
||||||
|
topicId: config.topicId,
|
||||||
|
hasOnChunk: !!config.onChunk,
|
||||||
|
hasTools: !!params.tools && Object.keys(params.tools).length > 0,
|
||||||
|
toolCount: params.tools ? Object.keys(params.tools).length : 0
|
||||||
|
})
|
||||||
|
|
||||||
// 根据条件构建插件数组
|
// 根据条件构建插件数组
|
||||||
const plugins = buildPlugins(middlewareConfig)
|
const plugins = buildPlugins(config)
|
||||||
console.log('this.config.providerId', this.config.providerId)
|
logger.debug('Built plugins for AI SDK', {
|
||||||
console.log('this.config.options', this.config.options)
|
pluginCount: plugins.length,
|
||||||
console.log('plugins', plugins)
|
pluginNames: plugins.map((p) => p.name),
|
||||||
|
providerId: this.config.providerId,
|
||||||
|
topicId: config.topicId
|
||||||
|
})
|
||||||
|
|
||||||
// 用构建好的插件数组创建executor
|
// 用构建好的插件数组创建executor
|
||||||
const executor = createExecutor(this.config.providerId, this.config.options, plugins)
|
const executor = createExecutor(this.config.providerId, this.config.options, plugins)
|
||||||
|
logger.debug('Created AI SDK executor', {
|
||||||
|
providerId: this.config.providerId,
|
||||||
|
hasOptions: !!this.config.options,
|
||||||
|
pluginCount: plugins.length
|
||||||
|
})
|
||||||
|
|
||||||
// 动态构建中间件数组
|
// 动态构建中间件数组
|
||||||
const middlewares = buildAiSdkMiddlewares(middlewareConfig)
|
const middlewares = buildAiSdkMiddlewares(config)
|
||||||
// console.log('构建的中间件:', middlewares)
|
logger.debug('Built AI SDK middlewares', {
|
||||||
|
middlewareCount: middlewares.length,
|
||||||
|
topicId: config.topicId
|
||||||
|
})
|
||||||
|
|
||||||
// 创建带有中间件的执行器
|
// 创建带有中间件的执行器
|
||||||
if (middlewareConfig.onChunk) {
|
if (config.onChunk) {
|
||||||
// 流式处理 - 使用适配器
|
// 流式处理 - 使用适配器
|
||||||
const adapter = new AiSdkToChunkAdapter(middlewareConfig.onChunk, middlewareConfig.mcpTools)
|
logger.info('Starting streaming with chunk adapter', {
|
||||||
console.log('最终params', params)
|
modelId,
|
||||||
|
hasMiddlewares: middlewares.length > 0,
|
||||||
|
middlewareCount: middlewares.length,
|
||||||
|
hasMcpTools: !!config.mcpTools,
|
||||||
|
mcpToolCount: config.mcpTools?.length || 0,
|
||||||
|
topicId: config.topicId
|
||||||
|
})
|
||||||
|
|
||||||
|
const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools)
|
||||||
|
|
||||||
|
logger.debug('Final params before streamText', {
|
||||||
|
modelId,
|
||||||
|
hasMessages: !!params.messages,
|
||||||
|
messageCount: params.messages?.length || 0,
|
||||||
|
hasTools: !!params.tools && Object.keys(params.tools).length > 0,
|
||||||
|
toolNames: params.tools ? Object.keys(params.tools) : [],
|
||||||
|
hasSystem: !!params.system,
|
||||||
|
topicId: config.topicId
|
||||||
|
})
|
||||||
|
|
||||||
const streamResult = await executor.streamText(
|
const streamResult = await executor.streamText(
|
||||||
modelId,
|
modelId,
|
||||||
params,
|
params,
|
||||||
middlewares.length > 0 ? { middlewares } : undefined
|
middlewares.length > 0 ? { middlewares } : undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info('StreamText call successful, processing stream', {
|
||||||
|
modelId,
|
||||||
|
topicId: config.topicId,
|
||||||
|
hasFullStream: !!streamResult.fullStream
|
||||||
|
})
|
||||||
|
|
||||||
const finalText = await adapter.processStream(streamResult)
|
const finalText = await adapter.processStream(streamResult)
|
||||||
|
|
||||||
|
logger.info('Stream processing completed', {
|
||||||
|
modelId,
|
||||||
|
topicId: config.topicId,
|
||||||
|
finalTextLength: finalText.length
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getText: () => finalText
|
getText: () => finalText
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 流式处理但没有 onChunk 回调
|
// 流式处理但没有 onChunk 回调
|
||||||
|
logger.info('Starting streaming without chunk callback', {
|
||||||
|
modelId,
|
||||||
|
hasMiddlewares: middlewares.length > 0,
|
||||||
|
middlewareCount: middlewares.length,
|
||||||
|
topicId: config.topicId
|
||||||
|
})
|
||||||
|
|
||||||
const streamResult = await executor.streamText(
|
const streamResult = await executor.streamText(
|
||||||
modelId,
|
modelId,
|
||||||
params,
|
params,
|
||||||
middlewares.length > 0 ? { middlewares } : undefined
|
middlewares.length > 0 ? { middlewares } : undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info('StreamText call successful, waiting for text', {
|
||||||
|
modelId,
|
||||||
|
topicId: config.topicId
|
||||||
|
})
|
||||||
|
// 强制消费流,不然await streamResult.text会阻塞
|
||||||
|
await streamResult?.consumeStream()
|
||||||
|
|
||||||
const finalText = await streamResult.text
|
const finalText = await streamResult.text
|
||||||
|
|
||||||
|
logger.info('Text extraction completed', {
|
||||||
|
modelId,
|
||||||
|
topicId: config.topicId,
|
||||||
|
finalTextLength: finalText.length
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getText: () => finalText
|
getText: () => finalText
|
||||||
}
|
}
|
||||||
@ -114,9 +293,14 @@ export default class ModernAiProvider {
|
|||||||
private async modernImageGeneration(
|
private async modernImageGeneration(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
params: StreamTextParams,
|
params: StreamTextParams,
|
||||||
middlewareConfig: AiSdkMiddlewareConfig
|
config: AiSdkMiddlewareConfig & {
|
||||||
|
assistant: Assistant
|
||||||
|
// topicId for tracing
|
||||||
|
topicId?: string
|
||||||
|
callType: string
|
||||||
|
}
|
||||||
): Promise<CompletionsResult> {
|
): Promise<CompletionsResult> {
|
||||||
const { onChunk } = middlewareConfig
|
const { onChunk } = config
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查 messages 是否存在
|
// 检查 messages 是否存在
|
||||||
@ -150,7 +334,7 @@ export default class ModernAiProvider {
|
|||||||
// 构建图像生成参数
|
// 构建图像生成参数
|
||||||
const imageParams = {
|
const imageParams = {
|
||||||
prompt,
|
prompt,
|
||||||
size: isNotSupportedImageSizeModel(middlewareConfig.model) ? undefined : ('1024x1024' as `${number}x${number}`), // 默认尺寸,使用正确的类型
|
size: isNotSupportedImageSizeModel(config.model) ? undefined : ('1024x1024' as `${number}x${number}`), // 默认尺寸,使用正确的类型
|
||||||
n: 1,
|
n: 1,
|
||||||
...(params.abortSignal && { abortSignal: params.abortSignal })
|
...(params.abortSignal && { abortSignal: params.abortSignal })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { AihubmixAPIClient } from '@renderer/aiCore/clients/AihubmixAPIClient'
|
import { AihubmixAPIClient } from '@renderer/aiCore/legacy/clients/AihubmixAPIClient'
|
||||||
import { AnthropicAPIClient } from '@renderer/aiCore/clients/anthropic/AnthropicAPIClient'
|
import { AnthropicAPIClient } from '@renderer/aiCore/legacy/clients/anthropic/AnthropicAPIClient'
|
||||||
import { ApiClientFactory } from '@renderer/aiCore/clients/ApiClientFactory'
|
import { ApiClientFactory } from '@renderer/aiCore/legacy/clients/ApiClientFactory'
|
||||||
import { GeminiAPIClient } from '@renderer/aiCore/clients/gemini/GeminiAPIClient'
|
import { GeminiAPIClient } from '@renderer/aiCore/legacy/clients/gemini/GeminiAPIClient'
|
||||||
import { VertexAPIClient } from '@renderer/aiCore/clients/gemini/VertexAPIClient'
|
import { VertexAPIClient } from '@renderer/aiCore/legacy/clients/gemini/VertexAPIClient'
|
||||||
import { NewAPIClient } from '@renderer/aiCore/clients/NewAPIClient'
|
import { NewAPIClient } from '@renderer/aiCore/legacy/clients/NewAPIClient'
|
||||||
import { OpenAIAPIClient } from '@renderer/aiCore/clients/openai/OpenAIApiClient'
|
import { OpenAIAPIClient } from '@renderer/aiCore/legacy/clients/openai/OpenAIApiClient'
|
||||||
import { OpenAIResponseAPIClient } from '@renderer/aiCore/clients/openai/OpenAIResponseAPIClient'
|
import { OpenAIResponseAPIClient } from '@renderer/aiCore/legacy/clients/openai/OpenAIResponseAPIClient'
|
||||||
import { EndpointType, Model, Provider } from '@renderer/types'
|
import { EndpointType, Model, Provider } from '@renderer/types'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
@ -3,7 +3,7 @@ import {
|
|||||||
LanguageModelV2Middleware,
|
LanguageModelV2Middleware,
|
||||||
simulateStreamingMiddleware
|
simulateStreamingMiddleware
|
||||||
} from '@cherrystudio/ai-core'
|
} from '@cherrystudio/ai-core'
|
||||||
import type { Assistant, BaseTool, Model, Provider } from '@renderer/types'
|
import type { BaseTool, Model, Provider } from '@renderer/types'
|
||||||
import type { Chunk } from '@renderer/types/chunk'
|
import type { Chunk } from '@renderer/types/chunk'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,8 +24,6 @@ export interface AiSdkMiddlewareConfig {
|
|||||||
enableWebSearch: boolean
|
enableWebSearch: boolean
|
||||||
enableGenerateImage: boolean
|
enableGenerateImage: boolean
|
||||||
mcpTools?: BaseTool[]
|
mcpTools?: BaseTool[]
|
||||||
// TODO assistant
|
|
||||||
assistant: Assistant
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,24 +1,40 @@
|
|||||||
import { AiPlugin } from '@cherrystudio/ai-core'
|
import { AiPlugin } from '@cherrystudio/ai-core'
|
||||||
import { createPromptToolUsePlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
|
import { createPromptToolUsePlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
|
||||||
|
import store from '@renderer/store'
|
||||||
|
import { Assistant } from '@renderer/types'
|
||||||
|
|
||||||
import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder'
|
import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder'
|
||||||
import reasoningTimePlugin from './reasoningTimePlugin'
|
import reasoningTimePlugin from './reasoningTimePlugin'
|
||||||
import { searchOrchestrationPlugin } from './searchOrchestrationPlugin'
|
import { searchOrchestrationPlugin } from './searchOrchestrationPlugin'
|
||||||
|
import { createTelemetryPlugin } from './telemetryPlugin'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据条件构建插件数组
|
* 根据条件构建插件数组
|
||||||
*/
|
*/
|
||||||
export function buildPlugins(middlewareConfig: AiSdkMiddlewareConfig): AiPlugin[] {
|
export function buildPlugins(
|
||||||
|
middlewareConfig: AiSdkMiddlewareConfig & { assistant: Assistant; topicId?: string }
|
||||||
|
): AiPlugin[] {
|
||||||
const plugins: AiPlugin[] = []
|
const plugins: AiPlugin[] = []
|
||||||
// 1. 总是添加通用插件
|
|
||||||
// plugins.push(textPlugin)
|
if (middlewareConfig.topicId && store.getState().settings.enableDeveloperMode) {
|
||||||
|
// 0. 添加 telemetry 插件
|
||||||
|
plugins.push(
|
||||||
|
createTelemetryPlugin({
|
||||||
|
enabled: true,
|
||||||
|
topicId: middlewareConfig.topicId,
|
||||||
|
assistant: middlewareConfig.assistant
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 模型内置搜索
|
||||||
if (middlewareConfig.enableWebSearch) {
|
if (middlewareConfig.enableWebSearch) {
|
||||||
// 内置了默认搜索参数,如果改的话可以传config进去
|
// 内置了默认搜索参数,如果改的话可以传config进去
|
||||||
plugins.push(webSearchPlugin())
|
plugins.push(webSearchPlugin())
|
||||||
}
|
}
|
||||||
// 2. 支持工具调用时添加搜索插件
|
// 2. 支持工具调用时添加搜索插件
|
||||||
if (middlewareConfig.isSupportedToolUse) {
|
if (middlewareConfig.isSupportedToolUse) {
|
||||||
plugins.push(searchOrchestrationPlugin(middlewareConfig.assistant))
|
plugins.push(searchOrchestrationPlugin(middlewareConfig.assistant, middlewareConfig.topicId || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 推理模型时添加推理插件
|
// 3. 推理模型时添加推理插件
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { AiRequestContext, ModelMessage } from '@cherrystudio/ai-core'
|
import type { AiRequestContext, ModelMessage } from '@cherrystudio/ai-core'
|
||||||
import { definePlugin } from '@cherrystudio/ai-core'
|
import { definePlugin } from '@cherrystudio/ai-core'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
// import { generateObject } from '@cherrystudio/ai-core'
|
// import { generateObject } from '@cherrystudio/ai-core'
|
||||||
import {
|
import {
|
||||||
SEARCH_SUMMARY_PROMPT,
|
SEARCH_SUMMARY_PROMPT,
|
||||||
@ -26,6 +27,8 @@ import { knowledgeSearchTool } from '../tools/KnowledgeSearchTool'
|
|||||||
import { memorySearchTool } from '../tools/MemorySearchTool'
|
import { memorySearchTool } from '../tools/MemorySearchTool'
|
||||||
import { webSearchToolWithPreExtractedKeywords } from '../tools/WebSearchTool'
|
import { webSearchToolWithPreExtractedKeywords } from '../tools/WebSearchTool'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('SearchOrchestrationPlugin')
|
||||||
|
|
||||||
const getMessageContent = (message: ModelMessage) => {
|
const getMessageContent = (message: ModelMessage) => {
|
||||||
if (typeof message.content === 'string') return message.content
|
if (typeof message.content === 'string') return message.content
|
||||||
return message.content.reduce((acc, part) => {
|
return message.content.reduce((acc, part) => {
|
||||||
@ -76,6 +79,7 @@ async function analyzeSearchIntent(
|
|||||||
context: AiRequestContext & {
|
context: AiRequestContext & {
|
||||||
isAnalyzing?: boolean
|
isAnalyzing?: boolean
|
||||||
}
|
}
|
||||||
|
topicId: string
|
||||||
}
|
}
|
||||||
): Promise<ExtractResults | undefined> {
|
): Promise<ExtractResults | undefined> {
|
||||||
const { shouldWebSearch = false, shouldKnowledgeSearch = false, lastAnswer, context } = options
|
const { shouldWebSearch = false, shouldKnowledgeSearch = false, lastAnswer, context } = options
|
||||||
@ -121,12 +125,28 @@ async function analyzeSearchIntent(
|
|||||||
// console.log('formattedPrompt', schema)
|
// console.log('formattedPrompt', schema)
|
||||||
try {
|
try {
|
||||||
context.isAnalyzing = true
|
context.isAnalyzing = true
|
||||||
const { text: result } = await context.executor.generateText(model.id, {
|
logger.info('Starting intent analysis generateText call', {
|
||||||
prompt: formattedPrompt
|
modelId: model.id,
|
||||||
|
topicId: options.topicId,
|
||||||
|
requestId: context.requestId,
|
||||||
|
hasWebSearch: needWebExtract,
|
||||||
|
hasKnowledgeSearch: needKnowledgeExtract
|
||||||
})
|
})
|
||||||
context.isAnalyzing = false
|
|
||||||
|
const { text: result } = await context.executor
|
||||||
|
.generateText(model.id, {
|
||||||
|
prompt: formattedPrompt
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
context.isAnalyzing = false
|
||||||
|
logger.info('Intent analysis generateText call completed', {
|
||||||
|
modelId: model.id,
|
||||||
|
topicId: options.topicId,
|
||||||
|
requestId: context.requestId
|
||||||
|
})
|
||||||
|
})
|
||||||
const parsedResult = extractInfoFromXML(result)
|
const parsedResult = extractInfoFromXML(result)
|
||||||
console.log('parsedResult', parsedResult)
|
logger.debug('Intent analysis result', { parsedResult })
|
||||||
|
|
||||||
// 根据需求过滤结果
|
// 根据需求过滤结果
|
||||||
return {
|
return {
|
||||||
@ -134,7 +154,7 @@ async function analyzeSearchIntent(
|
|||||||
knowledge: needKnowledgeExtract ? parsedResult?.knowledge : undefined
|
knowledge: needKnowledgeExtract ? parsedResult?.knowledge : undefined
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('analyze search intent error', e)
|
logger.error('Intent analysis failed', e as Error)
|
||||||
return getFallbackResult()
|
return getFallbackResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,27 +241,36 @@ async function storeConversationMemory(
|
|||||||
/**
|
/**
|
||||||
* 🎯 搜索编排插件
|
* 🎯 搜索编排插件
|
||||||
*/
|
*/
|
||||||
export const searchOrchestrationPlugin = (assistant: Assistant) => {
|
export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string) => {
|
||||||
// 存储意图分析结果
|
// 存储意图分析结果
|
||||||
const intentAnalysisResults: { [requestId: string]: ExtractResults } = {}
|
const intentAnalysisResults: { [requestId: string]: ExtractResults } = {}
|
||||||
const userMessages: { [requestId: string]: ModelMessage } = {}
|
const userMessages: { [requestId: string]: ModelMessage } = {}
|
||||||
|
let currentContext: AiRequestContext | null = null
|
||||||
|
|
||||||
console.log('searchOrchestrationPlugin', assistant)
|
console.log('searchOrchestrationPlugin', assistant)
|
||||||
|
|
||||||
return definePlugin({
|
return definePlugin({
|
||||||
name: 'search-orchestration',
|
name: 'search-orchestration',
|
||||||
enforce: 'pre', // 确保在其他插件之前执行
|
enforce: 'pre', // 确保在其他插件之前执行
|
||||||
|
|
||||||
|
configureContext: (context: AiRequestContext) => {
|
||||||
|
if (currentContext) {
|
||||||
|
context.isAnalyzing = currentContext.isAnalyzing
|
||||||
|
}
|
||||||
|
currentContext = context
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔍 Step 1: 意图识别阶段
|
* 🔍 Step 1: 意图识别阶段
|
||||||
*/
|
*/
|
||||||
onRequestStart: async (context: AiRequestContext) => {
|
onRequestStart: async (context: AiRequestContext) => {
|
||||||
console.log('onRequestStart', context.isAnalyzing)
|
console.log('onRequestStart', context)
|
||||||
if (context.isAnalyzing) return
|
if (context.isAnalyzing) return
|
||||||
console.log('🧠 [SearchOrchestration] Starting intent analysis...', context.requestId)
|
// console.log('🧠 [SearchOrchestration] Starting intent analysis...', context.requestId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messages = context.originalParams.messages
|
const messages = context.originalParams.messages
|
||||||
|
// console.log('🧠 [SearchOrchestration]', context.isAnalyzing)
|
||||||
if (!messages || messages.length === 0) {
|
if (!messages || messages.length === 0) {
|
||||||
console.log('🧠 [SearchOrchestration] No messages found, skipping analysis')
|
console.log('🧠 [SearchOrchestration] No messages found, skipping analysis')
|
||||||
return
|
return
|
||||||
@ -255,9 +284,9 @@ export const searchOrchestrationPlugin = (assistant: Assistant) => {
|
|||||||
|
|
||||||
// 判断是否需要各种搜索
|
// 判断是否需要各种搜索
|
||||||
const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id)
|
const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id)
|
||||||
console.log('knowledgeBaseIds', knowledgeBaseIds)
|
// console.log('knowledgeBaseIds', knowledgeBaseIds)
|
||||||
const hasKnowledgeBase = !isEmpty(knowledgeBaseIds)
|
const hasKnowledgeBase = !isEmpty(knowledgeBaseIds)
|
||||||
console.log('hasKnowledgeBase', hasKnowledgeBase)
|
// console.log('hasKnowledgeBase', hasKnowledgeBase)
|
||||||
const knowledgeRecognition = assistant.knowledgeRecognition || 'on'
|
const knowledgeRecognition = assistant.knowledgeRecognition || 'on'
|
||||||
const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState())
|
const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState())
|
||||||
|
|
||||||
@ -265,11 +294,11 @@ export const searchOrchestrationPlugin = (assistant: Assistant) => {
|
|||||||
const shouldKnowledgeSearch = hasKnowledgeBase && knowledgeRecognition === 'on'
|
const shouldKnowledgeSearch = hasKnowledgeBase && knowledgeRecognition === 'on'
|
||||||
const shouldMemorySearch = globalMemoryEnabled && assistant.enableMemory
|
const shouldMemorySearch = globalMemoryEnabled && assistant.enableMemory
|
||||||
|
|
||||||
console.log('🧠 [SearchOrchestration] Search capabilities:', {
|
// console.log('🧠 [SearchOrchestration] Search capabilities:', {
|
||||||
shouldWebSearch,
|
// shouldWebSearch,
|
||||||
hasKnowledgeBase,
|
// hasKnowledgeBase,
|
||||||
shouldMemorySearch
|
// shouldMemorySearch
|
||||||
})
|
// })
|
||||||
// 执行意图分析
|
// 执行意图分析
|
||||||
if (shouldWebSearch || hasKnowledgeBase) {
|
if (shouldWebSearch || hasKnowledgeBase) {
|
||||||
const analysisResult = await analyzeSearchIntent(lastUserMessage, assistant, {
|
const analysisResult = await analyzeSearchIntent(lastUserMessage, assistant, {
|
||||||
@ -277,12 +306,13 @@ export const searchOrchestrationPlugin = (assistant: Assistant) => {
|
|||||||
shouldKnowledgeSearch,
|
shouldKnowledgeSearch,
|
||||||
shouldMemorySearch,
|
shouldMemorySearch,
|
||||||
lastAnswer: lastAssistantMessage,
|
lastAnswer: lastAssistantMessage,
|
||||||
context
|
context,
|
||||||
|
topicId
|
||||||
})
|
})
|
||||||
|
|
||||||
if (analysisResult) {
|
if (analysisResult) {
|
||||||
intentAnalysisResults[context.requestId] = analysisResult
|
intentAnalysisResults[context.requestId] = analysisResult
|
||||||
console.log('🧠 [SearchOrchestration] Intent analysis completed:', analysisResult)
|
// console.log('🧠 [SearchOrchestration] Intent analysis completed:', analysisResult)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -296,7 +326,7 @@ export const searchOrchestrationPlugin = (assistant: Assistant) => {
|
|||||||
*/
|
*/
|
||||||
transformParams: async (params: any, context: AiRequestContext) => {
|
transformParams: async (params: any, context: AiRequestContext) => {
|
||||||
if (context.isAnalyzing) return params
|
if (context.isAnalyzing) return params
|
||||||
console.log('🔧 [SearchOrchestration] Configuring tools based on intent...', context.requestId)
|
// console.log('🔧 [SearchOrchestration] Configuring tools based on intent...', context.requestId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const analysisResult = intentAnalysisResults[context.requestId]
|
const analysisResult = intentAnalysisResults[context.requestId]
|
||||||
@ -316,7 +346,7 @@ export const searchOrchestrationPlugin = (assistant: Assistant) => {
|
|||||||
|
|
||||||
if (needsSearch) {
|
if (needsSearch) {
|
||||||
// onChunk({ type: ChunkType.EXTERNEL_TOOL_IN_PROGRESS })
|
// onChunk({ type: ChunkType.EXTERNEL_TOOL_IN_PROGRESS })
|
||||||
console.log('🌐 [SearchOrchestration] Adding web search tool with pre-extracted keywords')
|
// console.log('🌐 [SearchOrchestration] Adding web search tool with pre-extracted keywords')
|
||||||
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
|
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
|
||||||
assistant.webSearchProviderId,
|
assistant.webSearchProviderId,
|
||||||
analysisResult.websearch,
|
analysisResult.websearch,
|
||||||
@ -338,11 +368,12 @@ export const searchOrchestrationPlugin = (assistant: Assistant) => {
|
|||||||
question: [getMessageContent(userMessage) || 'search'],
|
question: [getMessageContent(userMessage) || 'search'],
|
||||||
rewrite: getMessageContent(userMessage) || 'search'
|
rewrite: getMessageContent(userMessage) || 'search'
|
||||||
}
|
}
|
||||||
console.log('📚 [SearchOrchestration] Adding knowledge search tool (force mode)')
|
// console.log('📚 [SearchOrchestration] Adding knowledge search tool (force mode)')
|
||||||
params.tools['builtin_knowledge_search'] = knowledgeSearchTool(
|
params.tools['builtin_knowledge_search'] = knowledgeSearchTool(
|
||||||
assistant,
|
assistant,
|
||||||
fallbackKeywords,
|
fallbackKeywords,
|
||||||
getMessageContent(userMessage)
|
getMessageContent(userMessage),
|
||||||
|
topicId
|
||||||
)
|
)
|
||||||
params.toolChoice = { type: 'tool', toolName: 'builtin_knowledge_search' }
|
params.toolChoice = { type: 'tool', toolName: 'builtin_knowledge_search' }
|
||||||
} else {
|
} else {
|
||||||
@ -353,12 +384,13 @@ export const searchOrchestrationPlugin = (assistant: Assistant) => {
|
|||||||
analysisResult.knowledge.question[0] !== 'not_needed'
|
analysisResult.knowledge.question[0] !== 'not_needed'
|
||||||
|
|
||||||
if (needsKnowledgeSearch && analysisResult.knowledge) {
|
if (needsKnowledgeSearch && analysisResult.knowledge) {
|
||||||
console.log('📚 [SearchOrchestration] Adding knowledge search tool (intent-based)')
|
// console.log('📚 [SearchOrchestration] Adding knowledge search tool (intent-based)')
|
||||||
const userMessage = userMessages[context.requestId]
|
const userMessage = userMessages[context.requestId]
|
||||||
params.tools['builtin_knowledge_search'] = knowledgeSearchTool(
|
params.tools['builtin_knowledge_search'] = knowledgeSearchTool(
|
||||||
assistant,
|
assistant,
|
||||||
analysisResult.knowledge,
|
analysisResult.knowledge,
|
||||||
getMessageContent(userMessage)
|
getMessageContent(userMessage),
|
||||||
|
topicId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -367,11 +399,11 @@ export const searchOrchestrationPlugin = (assistant: Assistant) => {
|
|||||||
// 🧠 记忆搜索工具配置
|
// 🧠 记忆搜索工具配置
|
||||||
const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState())
|
const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState())
|
||||||
if (globalMemoryEnabled && assistant.enableMemory) {
|
if (globalMemoryEnabled && assistant.enableMemory) {
|
||||||
console.log('🧠 [SearchOrchestration] Adding memory search tool')
|
// console.log('🧠 [SearchOrchestration] Adding memory search tool')
|
||||||
params.tools['builtin_memory_search'] = memorySearchTool()
|
params.tools['builtin_memory_search'] = memorySearchTool()
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔧 [SearchOrchestration] Tools configured:', Object.keys(params.tools))
|
// console.log('🔧 [SearchOrchestration] Tools configured:', Object.keys(params.tools))
|
||||||
return params
|
return params
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('🔧 [SearchOrchestration] Tool configuration failed:', error)
|
console.error('🔧 [SearchOrchestration] Tool configuration failed:', error)
|
||||||
@ -383,10 +415,10 @@ export const searchOrchestrationPlugin = (assistant: Assistant) => {
|
|||||||
* 💾 Step 3: 记忆存储阶段
|
* 💾 Step 3: 记忆存储阶段
|
||||||
*/
|
*/
|
||||||
|
|
||||||
onRequestEnd: async (context: AiRequestContext, result: any) => {
|
onRequestEnd: async (context: AiRequestContext) => {
|
||||||
// context.isAnalyzing = false
|
// context.isAnalyzing = false
|
||||||
console.log('context.isAnalyzing', context, result)
|
// console.log('context.isAnalyzing', context, result)
|
||||||
console.log('💾 [SearchOrchestration] Starting memory storage...', context.requestId)
|
// console.log('💾 [SearchOrchestration] Starting memory storage...', context.requestId)
|
||||||
if (context.isAnalyzing) return
|
if (context.isAnalyzing) return
|
||||||
try {
|
try {
|
||||||
const messages = context.originalParams.messages
|
const messages = context.originalParams.messages
|
||||||
|
|||||||
422
src/renderer/src/aiCore/plugins/telemetryPlugin.ts
Normal file
422
src/renderer/src/aiCore/plugins/telemetryPlugin.ts
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* Telemetry Plugin for AI SDK Integration
|
||||||
|
*
|
||||||
|
* 在 transformParams 钩子中注入 experimental_telemetry 参数,
|
||||||
|
* 实现 AI SDK trace 与现有手动 trace 系统的统一
|
||||||
|
* 集成 AiSdkSpanAdapter 将 AI SDK trace 数据转换为现有格式
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { definePlugin } from '@cherrystudio/ai-core'
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { Context, context as otelContext, Span, SpanContext, trace, Tracer } from '@opentelemetry/api'
|
||||||
|
import { currentSpan } from '@renderer/services/SpanManagerService'
|
||||||
|
import { webTraceService } from '@renderer/services/WebTraceService'
|
||||||
|
import { Assistant } from '@renderer/types'
|
||||||
|
|
||||||
|
import { AiSdkSpanAdapter } from '../trace/AiSdkSpanAdapter'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('TelemetryPlugin')
|
||||||
|
|
||||||
|
export interface TelemetryPluginConfig {
|
||||||
|
enabled?: boolean
|
||||||
|
recordInputs?: boolean
|
||||||
|
recordOutputs?: boolean
|
||||||
|
topicId: string
|
||||||
|
assistant: Assistant
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 Tracer,集成适配器转换逻辑
|
||||||
|
*/
|
||||||
|
class AdapterTracer {
|
||||||
|
private originalTracer: Tracer
|
||||||
|
private topicId?: string
|
||||||
|
private modelName?: string
|
||||||
|
private parentSpanContext?: SpanContext
|
||||||
|
private cachedParentContext?: Context
|
||||||
|
|
||||||
|
constructor(originalTracer: Tracer, topicId?: string, modelName?: string, parentSpanContext?: SpanContext) {
|
||||||
|
this.originalTracer = originalTracer
|
||||||
|
this.topicId = topicId
|
||||||
|
this.modelName = modelName
|
||||||
|
this.parentSpanContext = parentSpanContext
|
||||||
|
// 预构建一个包含父 SpanContext 的 Context,便于复用
|
||||||
|
try {
|
||||||
|
this.cachedParentContext = this.parentSpanContext
|
||||||
|
? trace.setSpanContext(otelContext.active(), this.parentSpanContext)
|
||||||
|
: undefined
|
||||||
|
} catch {
|
||||||
|
this.cachedParentContext = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('AdapterTracer created with parent context info', {
|
||||||
|
topicId,
|
||||||
|
modelName,
|
||||||
|
parentTraceId: this.parentSpanContext?.traceId,
|
||||||
|
parentSpanId: this.parentSpanContext?.spanId,
|
||||||
|
hasOriginalTracer: !!originalTracer
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSpan(name: string, options?: any, context?: any): Span {
|
||||||
|
// // 如果提供了父 SpanContext 且未显式传入 context,则使用父上下文
|
||||||
|
// const contextToUse = context ?? this.cachedParentContext ?? otelContext.active()
|
||||||
|
|
||||||
|
// const span = this.originalTracer.startSpan(name, options, contextToUse)
|
||||||
|
|
||||||
|
// // 标记父子关系,便于在转换阶段兜底重建层级
|
||||||
|
// try {
|
||||||
|
// if (this.parentSpanContext) {
|
||||||
|
// span.setAttribute('trace.parentSpanId', this.parentSpanContext.spanId)
|
||||||
|
// span.setAttribute('trace.parentTraceId', this.parentSpanContext.traceId)
|
||||||
|
// }
|
||||||
|
// if (this.topicId) {
|
||||||
|
// span.setAttribute('trace.topicId', this.topicId)
|
||||||
|
// }
|
||||||
|
// } catch (e) {
|
||||||
|
// logger.debug('Failed to set trace parent attributes', e as Error)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// logger.info('AI SDK span created via AdapterTracer', {
|
||||||
|
// spanName: name,
|
||||||
|
// spanId: span.spanContext().spanId,
|
||||||
|
// traceId: span.spanContext().traceId,
|
||||||
|
// parentTraceId: this.parentSpanContext?.traceId,
|
||||||
|
// topicId: this.topicId,
|
||||||
|
// modelName: this.modelName,
|
||||||
|
// traceIdMatches: this.parentSpanContext ? span.spanContext().traceId === this.parentSpanContext.traceId : undefined
|
||||||
|
// })
|
||||||
|
|
||||||
|
// // 包装 span 的 end 方法,在结束时进行数据转换
|
||||||
|
// const originalEnd = span.end.bind(span)
|
||||||
|
// span.end = (endTime?: any) => {
|
||||||
|
// logger.info('AI SDK span.end() called - about to convert span', {
|
||||||
|
// spanName: name,
|
||||||
|
// spanId: span.spanContext().spanId,
|
||||||
|
// traceId: span.spanContext().traceId,
|
||||||
|
// topicId: this.topicId,
|
||||||
|
// modelName: this.modelName
|
||||||
|
// })
|
||||||
|
|
||||||
|
// // 调用原始 end 方法
|
||||||
|
// originalEnd(endTime)
|
||||||
|
|
||||||
|
// // 转换并保存 span 数据
|
||||||
|
// try {
|
||||||
|
// logger.info('Converting AI SDK span to SpanEntity', {
|
||||||
|
// spanName: name,
|
||||||
|
// spanId: span.spanContext().spanId,
|
||||||
|
// traceId: span.spanContext().traceId,
|
||||||
|
// topicId: this.topicId,
|
||||||
|
// modelName: this.modelName
|
||||||
|
// })
|
||||||
|
// logger.info('spanspanspanspanspanspan', span)
|
||||||
|
// const spanEntity = AiSdkSpanAdapter.convertToSpanEntity({
|
||||||
|
// span,
|
||||||
|
// topicId: this.topicId,
|
||||||
|
// modelName: this.modelName
|
||||||
|
// })
|
||||||
|
|
||||||
|
// // 保存转换后的数据
|
||||||
|
// window.api.trace.saveEntity(spanEntity)
|
||||||
|
|
||||||
|
// logger.info('AI SDK span converted and saved successfully', {
|
||||||
|
// spanName: name,
|
||||||
|
// spanId: span.spanContext().spanId,
|
||||||
|
// traceId: span.spanContext().traceId,
|
||||||
|
// topicId: this.topicId,
|
||||||
|
// modelName: this.modelName,
|
||||||
|
// hasUsage: !!spanEntity.usage,
|
||||||
|
// usage: spanEntity.usage
|
||||||
|
// })
|
||||||
|
// } catch (error) {
|
||||||
|
// logger.error('Failed to convert AI SDK span', error as Error, {
|
||||||
|
// spanName: name,
|
||||||
|
// spanId: span.spanContext().spanId,
|
||||||
|
// traceId: span.spanContext().traceId,
|
||||||
|
// topicId: this.topicId,
|
||||||
|
// modelName: this.modelName
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return span
|
||||||
|
// }
|
||||||
|
|
||||||
|
startActiveSpan<F extends (span: Span) => any>(name: string, fn: F): ReturnType<F>
|
||||||
|
startActiveSpan<F extends (span: Span) => any>(name: string, options: any, fn: F): ReturnType<F>
|
||||||
|
startActiveSpan<F extends (span: Span) => any>(name: string, options: any, context: any, fn: F): ReturnType<F>
|
||||||
|
startActiveSpan<F extends (span: Span) => any>(name: string, arg2?: any, arg3?: any, arg4?: any): ReturnType<F> {
|
||||||
|
logger.info('AdapterTracer.startActiveSpan called', {
|
||||||
|
spanName: name,
|
||||||
|
topicId: this.topicId,
|
||||||
|
modelName: this.modelName,
|
||||||
|
argCount: arguments.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// 包装函数来添加span转换逻辑
|
||||||
|
const wrapFunction = (originalFn: F, span: Span): F => {
|
||||||
|
const wrappedFn = ((passedSpan: Span) => {
|
||||||
|
// 注入父子关系属性(兜底重建层级用)
|
||||||
|
try {
|
||||||
|
if (this.parentSpanContext) {
|
||||||
|
passedSpan.setAttribute('trace.parentSpanId', this.parentSpanContext.spanId)
|
||||||
|
passedSpan.setAttribute('trace.parentTraceId', this.parentSpanContext.traceId)
|
||||||
|
}
|
||||||
|
if (this.topicId) {
|
||||||
|
passedSpan.setAttribute('trace.topicId', this.topicId)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Failed to set trace parent attributes in startActiveSpan', e as Error)
|
||||||
|
}
|
||||||
|
// 包装span的end方法
|
||||||
|
const originalEnd = span.end.bind(span)
|
||||||
|
span.end = (endTime?: any) => {
|
||||||
|
logger.info('AI SDK span.end() called in startActiveSpan - about to convert span', {
|
||||||
|
spanName: name,
|
||||||
|
spanId: span.spanContext().spanId,
|
||||||
|
traceId: span.spanContext().traceId,
|
||||||
|
topicId: this.topicId,
|
||||||
|
modelName: this.modelName
|
||||||
|
})
|
||||||
|
|
||||||
|
// 调用原始 end 方法
|
||||||
|
originalEnd(endTime)
|
||||||
|
|
||||||
|
// 转换并保存 span 数据
|
||||||
|
try {
|
||||||
|
logger.info('Converting AI SDK span to SpanEntity (from startActiveSpan)', {
|
||||||
|
spanName: name,
|
||||||
|
spanId: span.spanContext().spanId,
|
||||||
|
traceId: span.spanContext().traceId,
|
||||||
|
topicId: this.topicId,
|
||||||
|
modelName: this.modelName
|
||||||
|
})
|
||||||
|
logger.info('span', span)
|
||||||
|
const spanEntity = AiSdkSpanAdapter.convertToSpanEntity({
|
||||||
|
span,
|
||||||
|
topicId: this.topicId,
|
||||||
|
modelName: this.modelName
|
||||||
|
})
|
||||||
|
|
||||||
|
// 保存转换后的数据
|
||||||
|
window.api.trace.saveEntity(spanEntity)
|
||||||
|
|
||||||
|
logger.info('AI SDK span converted and saved successfully (from startActiveSpan)', {
|
||||||
|
spanName: name,
|
||||||
|
spanId: span.spanContext().spanId,
|
||||||
|
traceId: span.spanContext().traceId,
|
||||||
|
topicId: this.topicId,
|
||||||
|
modelName: this.modelName,
|
||||||
|
hasUsage: !!spanEntity.usage,
|
||||||
|
usage: spanEntity.usage
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to convert AI SDK span (from startActiveSpan)', error as Error, {
|
||||||
|
spanName: name,
|
||||||
|
spanId: span.spanContext().spanId,
|
||||||
|
traceId: span.spanContext().traceId,
|
||||||
|
topicId: this.topicId,
|
||||||
|
modelName: this.modelName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalFn(passedSpan)
|
||||||
|
}) as F
|
||||||
|
return wrappedFn
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建包含父 SpanContext 的上下文(如果有的话)
|
||||||
|
const createContextWithParent = () => {
|
||||||
|
if (this.cachedParentContext) {
|
||||||
|
return this.cachedParentContext
|
||||||
|
}
|
||||||
|
if (this.parentSpanContext) {
|
||||||
|
try {
|
||||||
|
const ctx = trace.setSpanContext(otelContext.active(), this.parentSpanContext)
|
||||||
|
logger.info('Created active context with parent SpanContext for startActiveSpan', {
|
||||||
|
spanName: name,
|
||||||
|
parentTraceId: this.parentSpanContext.traceId,
|
||||||
|
parentSpanId: this.parentSpanContext.spanId,
|
||||||
|
topicId: this.topicId
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to create context with parent SpanContext in startActiveSpan', error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return otelContext.active()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据参数数量确定调用方式,注入包含mainTraceId的上下文
|
||||||
|
if (typeof arg2 === 'function') {
|
||||||
|
return this.originalTracer.startActiveSpan(name, {}, createContextWithParent(), (span: Span) => {
|
||||||
|
return wrapFunction(arg2, span)(span)
|
||||||
|
})
|
||||||
|
} else if (typeof arg3 === 'function') {
|
||||||
|
return this.originalTracer.startActiveSpan(name, arg2, createContextWithParent(), (span: Span) => {
|
||||||
|
return wrapFunction(arg3, span)(span)
|
||||||
|
})
|
||||||
|
} else if (typeof arg4 === 'function') {
|
||||||
|
// 如果调用方提供了 context,则保留以维护嵌套关系;否则回退到父上下文
|
||||||
|
const ctx = arg3 ?? createContextWithParent()
|
||||||
|
return this.originalTracer.startActiveSpan(name, arg2, ctx, (span: Span) => {
|
||||||
|
return wrapFunction(arg4, span)(span)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid arguments for startActiveSpan')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTelemetryPlugin(config: TelemetryPluginConfig) {
|
||||||
|
const { enabled = true, recordInputs = true, recordOutputs = true, topicId } = config
|
||||||
|
|
||||||
|
return definePlugin({
|
||||||
|
name: 'telemetryPlugin',
|
||||||
|
enforce: 'pre', // 在其他插件之前执行,确保 telemetry 配置被正确注入
|
||||||
|
|
||||||
|
transformParams: (params, context) => {
|
||||||
|
if (!enabled) {
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取共享的 tracer
|
||||||
|
const originalTracer = webTraceService.getTracer()
|
||||||
|
if (!originalTracer) {
|
||||||
|
logger.warn('No tracer available from WebTraceService')
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取topicId和modelName
|
||||||
|
const effectiveTopicId = context.topicId || topicId
|
||||||
|
// 使用与父span创建时一致的modelName - 应该是完整的modelId
|
||||||
|
const modelName = config.assistant.model?.name || context.modelId
|
||||||
|
|
||||||
|
// 获取当前活跃的 span,确保 AI SDK spans 与手动 spans 在同一个 trace 中
|
||||||
|
let parentSpan: Span | undefined = undefined
|
||||||
|
let parentSpanContext: SpanContext | undefined = undefined
|
||||||
|
|
||||||
|
// 只有在有topicId时才尝试查找父span
|
||||||
|
if (effectiveTopicId) {
|
||||||
|
try {
|
||||||
|
// 从 SpanManagerService 获取当前的 span
|
||||||
|
logger.info('Attempting to find parent span', {
|
||||||
|
topicId: effectiveTopicId,
|
||||||
|
requestId: context.requestId,
|
||||||
|
modelName: modelName,
|
||||||
|
contextModelId: context.modelId,
|
||||||
|
providerId: context.providerId
|
||||||
|
})
|
||||||
|
|
||||||
|
parentSpan = currentSpan(effectiveTopicId, modelName)
|
||||||
|
if (parentSpan) {
|
||||||
|
// 直接使用父 span 的 SpanContext,避免手动拼装字段遗漏
|
||||||
|
parentSpanContext = parentSpan.spanContext()
|
||||||
|
logger.info('Found active parent span for AI SDK', {
|
||||||
|
parentSpanId: parentSpanContext.spanId,
|
||||||
|
parentTraceId: parentSpanContext.traceId,
|
||||||
|
topicId: effectiveTopicId,
|
||||||
|
requestId: context.requestId,
|
||||||
|
modelName: modelName
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.warn('No active parent span found in SpanManagerService', {
|
||||||
|
topicId: effectiveTopicId,
|
||||||
|
requestId: context.requestId,
|
||||||
|
modelId: context.modelId,
|
||||||
|
modelName: modelName,
|
||||||
|
providerId: context.providerId,
|
||||||
|
// 更详细的调试信息
|
||||||
|
searchedModelName: modelName,
|
||||||
|
contextModelId: context.modelId,
|
||||||
|
isAnalyzing: context.isAnalyzing
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting current span from SpanManagerService', error as Error, {
|
||||||
|
topicId: effectiveTopicId,
|
||||||
|
requestId: context.requestId,
|
||||||
|
modelName: modelName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug('No topicId provided, skipping parent span lookup', {
|
||||||
|
requestId: context.requestId,
|
||||||
|
contextTopicId: context.topicId,
|
||||||
|
configTopicId: topicId,
|
||||||
|
modelName: modelName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建适配器包装的 tracer,传入获取到的父 SpanContext
|
||||||
|
const adapterTracer = new AdapterTracer(originalTracer, effectiveTopicId, modelName, parentSpanContext)
|
||||||
|
|
||||||
|
// 注入 AI SDK telemetry 配置
|
||||||
|
const telemetryConfig = {
|
||||||
|
isEnabled: true,
|
||||||
|
recordInputs,
|
||||||
|
recordOutputs,
|
||||||
|
tracer: adapterTracer, // 使用包装后的 tracer
|
||||||
|
functionId: `ai-request-${context.requestId}`,
|
||||||
|
metadata: {
|
||||||
|
providerId: context.providerId,
|
||||||
|
modelId: context.modelId,
|
||||||
|
topicId: effectiveTopicId,
|
||||||
|
requestId: context.requestId,
|
||||||
|
modelName: modelName,
|
||||||
|
// 确保topicId也作为标准属性传递
|
||||||
|
'trace.topicId': effectiveTopicId,
|
||||||
|
'trace.modelName': modelName,
|
||||||
|
// 添加父span信息用于调试
|
||||||
|
parentSpanId: parentSpanContext?.spanId,
|
||||||
|
parentTraceId: parentSpanContext?.traceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有父span,尝试在telemetry配置中设置父上下文
|
||||||
|
if (parentSpan) {
|
||||||
|
try {
|
||||||
|
// 设置活跃上下文,确保 AI SDK spans 在正确的 trace 上下文中创建
|
||||||
|
const activeContext = trace.setSpan(otelContext.active(), parentSpan)
|
||||||
|
|
||||||
|
// 更新全局上下文
|
||||||
|
otelContext.with(activeContext, () => {
|
||||||
|
logger.debug('Updated active context with parent span')
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('Set parent context for AI SDK spans', {
|
||||||
|
parentSpanId: parentSpanContext?.spanId,
|
||||||
|
parentTraceId: parentSpanContext?.traceId,
|
||||||
|
hasActiveContext: !!activeContext,
|
||||||
|
hasParentSpan: !!parentSpan
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to set parent context in telemetry config', error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Injecting AI SDK telemetry config with adapter', {
|
||||||
|
requestId: context.requestId,
|
||||||
|
topicId: effectiveTopicId,
|
||||||
|
modelId: context.modelId,
|
||||||
|
modelName: modelName,
|
||||||
|
hasParentSpan: !!parentSpan,
|
||||||
|
parentSpanId: parentSpanContext?.spanId,
|
||||||
|
parentTraceId: parentSpanContext?.traceId,
|
||||||
|
functionId: telemetryConfig.functionId,
|
||||||
|
hasTracer: !!telemetryConfig.tracer,
|
||||||
|
tracerType: telemetryConfig.tracer?.constructor?.name || 'unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...params,
|
||||||
|
experimental_telemetry: telemetryConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认导出便于使用
|
||||||
|
export default createTelemetryPlugin
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
|
||||||
import { processKnowledgeSearch } from '@renderer/services/KnowledgeService'
|
import { processKnowledgeSearch } from '@renderer/services/KnowledgeService'
|
||||||
import type { Assistant, KnowledgeReference } from '@renderer/types'
|
import type { Assistant, KnowledgeReference } from '@renderer/types'
|
||||||
import { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract'
|
import { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract'
|
||||||
@ -12,6 +13,7 @@ import { z } from 'zod'
|
|||||||
export const knowledgeSearchTool = (
|
export const knowledgeSearchTool = (
|
||||||
assistant: Assistant,
|
assistant: Assistant,
|
||||||
extractedKeywords: KnowledgeExtractResults,
|
extractedKeywords: KnowledgeExtractResults,
|
||||||
|
topicId: string,
|
||||||
userMessage?: string
|
userMessage?: string
|
||||||
) => {
|
) => {
|
||||||
return tool({
|
return tool({
|
||||||
@ -21,7 +23,8 @@ export const knowledgeSearchTool = (
|
|||||||
Pre-extracted search queries: "${extractedKeywords.question.join(', ')}"
|
Pre-extracted search queries: "${extractedKeywords.question.join(', ')}"
|
||||||
Rewritten query: "${extractedKeywords.rewrite}"
|
Rewritten query: "${extractedKeywords.rewrite}"
|
||||||
|
|
||||||
This tool searches your knowledge base for relevant documents and returns results for easy reference.
|
This tool searches for relevant information and formats results for easy citation. The returned sources should be cited using [1], [2], etc. format in your response.
|
||||||
|
|
||||||
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
|
Call this tool to execute the search. You can optionally provide additional context to refine the search.`,
|
||||||
|
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
@ -40,7 +43,13 @@ Call this tool to execute the search. You can optionally provide additional cont
|
|||||||
|
|
||||||
// 检查是否有知识库
|
// 检查是否有知识库
|
||||||
if (!hasKnowledgeBase) {
|
if (!hasKnowledgeBase) {
|
||||||
return []
|
return {
|
||||||
|
summary: 'No knowledge base configured for this assistant.',
|
||||||
|
knowledgeReferences: [],
|
||||||
|
sources: '',
|
||||||
|
instructions: '',
|
||||||
|
rawResults: []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalQueries = [...extractedKeywords.question]
|
let finalQueries = [...extractedKeywords.question]
|
||||||
@ -59,7 +68,13 @@ Call this tool to execute the search. You can optionally provide additional cont
|
|||||||
|
|
||||||
// 检查是否需要搜索
|
// 检查是否需要搜索
|
||||||
if (finalQueries[0] === 'not_needed') {
|
if (finalQueries[0] === 'not_needed') {
|
||||||
return []
|
return {
|
||||||
|
summary: 'No search needed based on the query analysis.',
|
||||||
|
knowledgeReferences: [],
|
||||||
|
sources: '',
|
||||||
|
instructions: '',
|
||||||
|
rawResults: []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建搜索条件
|
// 构建搜索条件
|
||||||
@ -89,21 +104,42 @@ Call this tool to execute the search. You can optionally provide additional cont
|
|||||||
console.log('Knowledge search extractResults:', extractResults)
|
console.log('Knowledge search extractResults:', extractResults)
|
||||||
|
|
||||||
// 执行知识库搜索
|
// 执行知识库搜索
|
||||||
const knowledgeReferences = await processKnowledgeSearch(extractResults, knowledgeBaseIds)
|
const knowledgeReferences = await processKnowledgeSearch(extractResults, knowledgeBaseIds, topicId)
|
||||||
|
const knowledgeReferencesData = knowledgeReferences.map((ref: KnowledgeReference) => ({
|
||||||
// 返回结果数组
|
|
||||||
return knowledgeReferences.map((ref: KnowledgeReference) => ({
|
|
||||||
id: ref.id,
|
id: ref.id,
|
||||||
content: ref.content,
|
content: ref.content,
|
||||||
sourceUrl: ref.sourceUrl,
|
sourceUrl: ref.sourceUrl,
|
||||||
type: ref.type,
|
type: ref.type,
|
||||||
file: ref.file
|
file: ref.file
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const referenceContent = `\`\`\`json\n${JSON.stringify(knowledgeReferencesData, null, 2)}\n\`\`\``
|
||||||
|
|
||||||
|
const fullInstructions = REFERENCE_PROMPT.replace(
|
||||||
|
'{question}',
|
||||||
|
"Based on the knowledge references, please answer the user's question with proper citations."
|
||||||
|
).replace('{references}', referenceContent)
|
||||||
|
|
||||||
|
// 返回结果
|
||||||
|
return {
|
||||||
|
summary: `Found ${knowledgeReferencesData.length} relevant sources. Use [number] format to cite specific information.`,
|
||||||
|
knowledgeReferences: knowledgeReferencesData,
|
||||||
|
// sources: citationData
|
||||||
|
// .map((source) => `[${source.number}] ${source.title}\n${source.content}\nURL: ${source.url}`)
|
||||||
|
// .join('\n\n'),
|
||||||
|
instructions: fullInstructions
|
||||||
|
// rawResults: citationData
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('🔍 [KnowledgeSearchTool] Search failed:', error)
|
console.error('🔍 [KnowledgeSearchTool] Search failed:', error)
|
||||||
|
|
||||||
// 返回空数组而不是抛出错误,避免中断对话流程
|
// 返回空对象而不是抛出错误,避免中断对话流程
|
||||||
return []
|
return {
|
||||||
|
summary: `Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
knowledgeReferences: [],
|
||||||
|
instructions: ''
|
||||||
|
// rawResults: []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
655
src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts
Normal file
655
src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts
Normal file
@ -0,0 +1,655 @@
|
|||||||
|
/**
|
||||||
|
* AI SDK Span Adapter
|
||||||
|
*
|
||||||
|
* 将 AI SDK 的 telemetry 数据转换为现有的 SpanEntity 格式
|
||||||
|
* 注意 AI SDK 的层级结构:ai.xxx 是一个层级,ai.xxx.xxx 是对应层级下的子集
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||||
|
import { Span, SpanKind, SpanStatusCode } from '@opentelemetry/api'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('AiSdkSpanAdapter')
|
||||||
|
|
||||||
|
export interface AiSdkSpanData {
|
||||||
|
span: Span
|
||||||
|
topicId?: string
|
||||||
|
modelName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展接口用于访问span的内部数据
|
||||||
|
interface SpanWithInternals extends Span {
|
||||||
|
_spanProcessor?: any
|
||||||
|
_attributes?: Record<string, any>
|
||||||
|
_events?: any[]
|
||||||
|
name?: string
|
||||||
|
startTime?: [number, number]
|
||||||
|
endTime?: [number, number] | null
|
||||||
|
status?: { code: SpanStatusCode; message?: string }
|
||||||
|
kind?: SpanKind
|
||||||
|
ended?: boolean
|
||||||
|
parentSpanId?: string
|
||||||
|
links?: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AiSdkSpanAdapter {
|
||||||
|
/**
|
||||||
|
* 将 AI SDK span 转换为 SpanEntity 格式
|
||||||
|
*/
|
||||||
|
static convertToSpanEntity(spanData: AiSdkSpanData): SpanEntity {
|
||||||
|
const { span, topicId, modelName } = spanData
|
||||||
|
const spanContext = span.spanContext()
|
||||||
|
|
||||||
|
// 尝试从不同方式获取span数据
|
||||||
|
const spanWithInternals = span as SpanWithInternals
|
||||||
|
let attributes: Record<string, any> = {}
|
||||||
|
let events: any[] = []
|
||||||
|
let spanName = 'unknown'
|
||||||
|
let spanStatus = { code: SpanStatusCode.UNSET }
|
||||||
|
let spanKind = SpanKind.INTERNAL
|
||||||
|
let startTime: [number, number] = [0, 0]
|
||||||
|
let endTime: [number, number] | null = null
|
||||||
|
let ended = false
|
||||||
|
let parentSpanId = ''
|
||||||
|
let links: any[] = []
|
||||||
|
|
||||||
|
// 详细记录span的结构信息用于调试
|
||||||
|
logger.debug('Debugging span structure', {
|
||||||
|
hasInternalAttributes: !!spanWithInternals._attributes,
|
||||||
|
hasGetAttributes: typeof (span as any).getAttributes === 'function',
|
||||||
|
spanKeys: Object.keys(span),
|
||||||
|
spanInternalKeys: Object.keys(spanWithInternals),
|
||||||
|
spanContext: span.spanContext(),
|
||||||
|
// 尝试获取所有可能的属性路径
|
||||||
|
attributesPath1: spanWithInternals._attributes,
|
||||||
|
attributesPath2: (span as any).attributes,
|
||||||
|
attributesPath3: (span as any)._spanData?.attributes,
|
||||||
|
attributesPath4: (span as any).resource?.attributes
|
||||||
|
})
|
||||||
|
|
||||||
|
// 尝试多种方式获取attributes
|
||||||
|
if (spanWithInternals._attributes) {
|
||||||
|
attributes = spanWithInternals._attributes
|
||||||
|
logger.debug('Found attributes via _attributes', { attributeCount: Object.keys(attributes).length })
|
||||||
|
} else if (typeof (span as any).getAttributes === 'function') {
|
||||||
|
attributes = (span as any).getAttributes()
|
||||||
|
logger.debug('Found attributes via getAttributes()', { attributeCount: Object.keys(attributes).length })
|
||||||
|
} else if ((span as any).attributes) {
|
||||||
|
attributes = (span as any).attributes
|
||||||
|
logger.debug('Found attributes via direct attributes property', {
|
||||||
|
attributeCount: Object.keys(attributes).length
|
||||||
|
})
|
||||||
|
} else if ((span as any)._spanData?.attributes) {
|
||||||
|
attributes = (span as any)._spanData.attributes
|
||||||
|
logger.debug('Found attributes via _spanData.attributes', { attributeCount: Object.keys(attributes).length })
|
||||||
|
} else {
|
||||||
|
// 尝试从span的其他属性获取
|
||||||
|
logger.warn('无法获取span attributes,尝试备用方法', {
|
||||||
|
availableKeys: Object.keys(span),
|
||||||
|
spanType: span.constructor.name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取其他属性
|
||||||
|
if (spanWithInternals._events) {
|
||||||
|
events = spanWithInternals._events
|
||||||
|
}
|
||||||
|
if (spanWithInternals.name) {
|
||||||
|
spanName = spanWithInternals.name
|
||||||
|
}
|
||||||
|
if (spanWithInternals.status) {
|
||||||
|
spanStatus = spanWithInternals.status
|
||||||
|
}
|
||||||
|
if (spanWithInternals.kind !== undefined) {
|
||||||
|
spanKind = spanWithInternals.kind
|
||||||
|
}
|
||||||
|
if (spanWithInternals.startTime) {
|
||||||
|
startTime = spanWithInternals.startTime
|
||||||
|
}
|
||||||
|
if (spanWithInternals.endTime) {
|
||||||
|
endTime = spanWithInternals.endTime
|
||||||
|
}
|
||||||
|
if (spanWithInternals.ended !== undefined) {
|
||||||
|
ended = spanWithInternals.ended
|
||||||
|
}
|
||||||
|
if (spanWithInternals.parentSpanId) {
|
||||||
|
parentSpanId = spanWithInternals.parentSpanId
|
||||||
|
}
|
||||||
|
// 兜底:尝试从 attributes 中读取我们注入的父信息
|
||||||
|
if (!parentSpanId && attributes['trace.parentSpanId']) {
|
||||||
|
parentSpanId = attributes['trace.parentSpanId']
|
||||||
|
}
|
||||||
|
if (spanWithInternals.links) {
|
||||||
|
links = spanWithInternals.links
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 AI SDK 特有的数据
|
||||||
|
const tokenUsage = this.extractTokenUsage(attributes)
|
||||||
|
const { inputs, outputs } = this.extractInputsOutputs(attributes)
|
||||||
|
const formattedSpanName = this.formatSpanName(spanName)
|
||||||
|
const spanTag = this.extractSpanTag(spanName, attributes)
|
||||||
|
const typeSpecificData = this.extractSpanTypeSpecificData(attributes)
|
||||||
|
|
||||||
|
// 详细记录转换过程
|
||||||
|
const operationId = attributes['ai.operationId']
|
||||||
|
logger.info('Converting AI SDK span to SpanEntity', {
|
||||||
|
spanName: spanName,
|
||||||
|
operationId,
|
||||||
|
spanTag,
|
||||||
|
hasTokenUsage: !!tokenUsage,
|
||||||
|
hasInputs: !!inputs,
|
||||||
|
hasOutputs: !!outputs,
|
||||||
|
hasTypeSpecificData: Object.keys(typeSpecificData).length > 0,
|
||||||
|
attributesCount: Object.keys(attributes).length,
|
||||||
|
topicId,
|
||||||
|
modelName,
|
||||||
|
spanId: spanContext.spanId,
|
||||||
|
traceId: spanContext.traceId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tokenUsage) {
|
||||||
|
logger.info('Token usage data found', {
|
||||||
|
spanName: spanName,
|
||||||
|
operationId,
|
||||||
|
usage: tokenUsage,
|
||||||
|
spanId: spanContext.spanId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputs || outputs) {
|
||||||
|
logger.info('Input/Output data extracted', {
|
||||||
|
spanName: spanName,
|
||||||
|
operationId,
|
||||||
|
hasInputs: !!inputs,
|
||||||
|
hasOutputs: !!outputs,
|
||||||
|
inputKeys: inputs ? Object.keys(inputs) : [],
|
||||||
|
outputKeys: outputs ? Object.keys(outputs) : [],
|
||||||
|
spanId: spanContext.spanId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(typeSpecificData).length > 0) {
|
||||||
|
logger.info('Type-specific data extracted', {
|
||||||
|
spanName: spanName,
|
||||||
|
operationId,
|
||||||
|
typeSpecificKeys: Object.keys(typeSpecificData),
|
||||||
|
spanId: spanContext.spanId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 SpanEntity 格式
|
||||||
|
const spanEntity: SpanEntity = {
|
||||||
|
id: spanContext.spanId,
|
||||||
|
name: formattedSpanName,
|
||||||
|
parentId: parentSpanId,
|
||||||
|
traceId: spanContext.traceId,
|
||||||
|
status: this.convertSpanStatus(spanStatus.code),
|
||||||
|
kind: this.convertSpanKind(spanKind),
|
||||||
|
attributes: {
|
||||||
|
...this.filterRelevantAttributes(attributes),
|
||||||
|
...typeSpecificData,
|
||||||
|
inputs: inputs,
|
||||||
|
outputs: outputs,
|
||||||
|
tags: spanTag,
|
||||||
|
modelName: modelName || this.extractModelFromAttributes(attributes) || ''
|
||||||
|
},
|
||||||
|
isEnd: ended,
|
||||||
|
events: events,
|
||||||
|
startTime: this.convertTimestamp(startTime),
|
||||||
|
endTime: endTime ? this.convertTimestamp(endTime) : null,
|
||||||
|
links: links,
|
||||||
|
topicId: topicId,
|
||||||
|
usage: tokenUsage,
|
||||||
|
modelName: modelName || this.extractModelFromAttributes(attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('AI SDK span successfully converted to SpanEntity', {
|
||||||
|
spanName: spanName,
|
||||||
|
operationId,
|
||||||
|
spanId: spanContext.spanId,
|
||||||
|
traceId: spanContext.traceId,
|
||||||
|
topicId,
|
||||||
|
entityId: spanEntity.id,
|
||||||
|
hasUsage: !!spanEntity.usage,
|
||||||
|
status: spanEntity.status,
|
||||||
|
tags: spanEntity.attributes?.tags
|
||||||
|
})
|
||||||
|
|
||||||
|
return spanEntity
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 AI SDK attributes 中提取 token usage
|
||||||
|
* 支持多种格式:
|
||||||
|
* - AI SDK 标准格式: ai.usage.completionTokens, ai.usage.promptTokens
|
||||||
|
* - 完整usage对象格式: ai.usage (JSON字符串或对象)
|
||||||
|
*/
|
||||||
|
private static extractTokenUsage(attributes: Record<string, any>): TokenUsage | undefined {
|
||||||
|
logger.debug('Extracting token usage from attributes', {
|
||||||
|
attributeKeys: Object.keys(attributes),
|
||||||
|
usageRelatedKeys: Object.keys(attributes).filter((key) => key.includes('usage') || key.includes('token')),
|
||||||
|
fullAttributes: attributes
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputsTokenKeys = [
|
||||||
|
// base span
|
||||||
|
'ai.usage.promptTokens',
|
||||||
|
// LLM span
|
||||||
|
'gen_ai.usage.input_tokens'
|
||||||
|
]
|
||||||
|
const outputTokenKeys = [
|
||||||
|
// base span
|
||||||
|
'ai.usage.completionTokens',
|
||||||
|
// LLM span
|
||||||
|
'gen_ai.usage.output_tokens'
|
||||||
|
]
|
||||||
|
|
||||||
|
const completionTokens = attributes[inputsTokenKeys.find((key) => attributes[key]) || '']
|
||||||
|
const promptTokens = attributes[outputTokenKeys.find((key) => attributes[key]) || '']
|
||||||
|
|
||||||
|
if (completionTokens !== undefined || promptTokens !== undefined) {
|
||||||
|
const usage: TokenUsage = {
|
||||||
|
prompt_tokens: Number(promptTokens) || 0,
|
||||||
|
completion_tokens: Number(completionTokens) || 0,
|
||||||
|
total_tokens: (Number(promptTokens) || 0) + (Number(completionTokens) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Extracted token usage from AI SDK standard attributes', {
|
||||||
|
usage,
|
||||||
|
foundStandardAttributes: {
|
||||||
|
'ai.usage.completionTokens': completionTokens,
|
||||||
|
'ai.usage.promptTokens': promptTokens
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于不包含token usage的spans(如tool calls),这是正常的
|
||||||
|
logger.debug('No token usage found in span attributes (normal for tool calls)', {
|
||||||
|
availableKeys: Object.keys(attributes),
|
||||||
|
usageKeys: Object.keys(attributes).filter((key) => key.includes('usage') || key.includes('token'))
|
||||||
|
})
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 AI SDK attributes 中提取 inputs 和 outputs
|
||||||
|
* 根据AI SDK文档按不同span类型精确映射
|
||||||
|
*/
|
||||||
|
private static extractInputsOutputs(attributes: Record<string, any>): { inputs?: any; outputs?: any } {
|
||||||
|
const operationId = attributes['ai.operationId']
|
||||||
|
let inputs: any = undefined
|
||||||
|
let outputs: any = undefined
|
||||||
|
|
||||||
|
logger.debug('Extracting inputs/outputs by operation type', {
|
||||||
|
operationId,
|
||||||
|
availableKeys: Object.keys(attributes).filter(
|
||||||
|
(key) => key.includes('prompt') || key.includes('response') || key.includes('toolCall')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据AI SDK文档按操作类型提取数据
|
||||||
|
switch (operationId) {
|
||||||
|
case 'ai.generateText':
|
||||||
|
case 'ai.streamText':
|
||||||
|
// 顶层LLM spans: ai.prompt 包含输入
|
||||||
|
inputs = {
|
||||||
|
prompt: this.parseAttributeValue(attributes['ai.prompt'])
|
||||||
|
}
|
||||||
|
outputs = this.extractLLMOutputs(attributes)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ai.generateText.doGenerate':
|
||||||
|
case 'ai.streamText.doStream':
|
||||||
|
// Provider spans: ai.prompt.messages 包含详细输入
|
||||||
|
inputs = {
|
||||||
|
messages: this.parseAttributeValue(attributes['ai.prompt.messages']),
|
||||||
|
tools: this.parseAttributeValue(attributes['ai.prompt.tools']),
|
||||||
|
toolChoice: this.parseAttributeValue(attributes['ai.prompt.toolChoice'])
|
||||||
|
}
|
||||||
|
outputs = this.extractProviderOutputs(attributes)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ai.toolCall':
|
||||||
|
// Tool call spans: 工具参数和结果
|
||||||
|
inputs = {
|
||||||
|
toolName: attributes['ai.toolCall.name'],
|
||||||
|
toolId: attributes['ai.toolCall.id'],
|
||||||
|
args: this.parseAttributeValue(attributes['ai.toolCall.args'])
|
||||||
|
}
|
||||||
|
outputs = {
|
||||||
|
result: this.parseAttributeValue(attributes['ai.toolCall.result'])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 回退到通用逻辑
|
||||||
|
inputs = this.extractGenericInputs(attributes)
|
||||||
|
outputs = this.extractGenericOutputs(attributes)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Extracted inputs/outputs', {
|
||||||
|
operationId,
|
||||||
|
hasInputs: !!inputs,
|
||||||
|
hasOutputs: !!outputs,
|
||||||
|
inputKeys: inputs ? Object.keys(inputs) : [],
|
||||||
|
outputKeys: outputs ? Object.keys(outputs) : []
|
||||||
|
})
|
||||||
|
|
||||||
|
return { inputs, outputs }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取LLM顶层spans的输出
|
||||||
|
*/
|
||||||
|
private static extractLLMOutputs(attributes: Record<string, any>): any {
|
||||||
|
const outputs: any = {}
|
||||||
|
|
||||||
|
if (attributes['ai.response.text']) {
|
||||||
|
outputs.text = attributes['ai.response.text']
|
||||||
|
}
|
||||||
|
if (attributes['ai.response.toolCalls']) {
|
||||||
|
outputs.toolCalls = this.parseAttributeValue(attributes['ai.response.toolCalls'])
|
||||||
|
}
|
||||||
|
if (attributes['ai.response.finishReason']) {
|
||||||
|
outputs.finishReason = attributes['ai.response.finishReason']
|
||||||
|
}
|
||||||
|
if (attributes['ai.settings.maxOutputTokens']) {
|
||||||
|
outputs.maxOutputTokens = attributes['ai.settings.maxOutputTokens']
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取Provider spans的输出
|
||||||
|
*/
|
||||||
|
private static extractProviderOutputs(attributes: Record<string, any>): any {
|
||||||
|
const outputs: any = {}
|
||||||
|
|
||||||
|
if (attributes['ai.response.text']) {
|
||||||
|
outputs.text = attributes['ai.response.text']
|
||||||
|
}
|
||||||
|
if (attributes['ai.response.toolCalls']) {
|
||||||
|
outputs.toolCalls = this.parseAttributeValue(attributes['ai.response.toolCalls'])
|
||||||
|
}
|
||||||
|
if (attributes['ai.response.finishReason']) {
|
||||||
|
outputs.finishReason = attributes['ai.response.finishReason']
|
||||||
|
}
|
||||||
|
|
||||||
|
// doStream特有的性能指标
|
||||||
|
if (attributes['ai.response.msToFirstChunk']) {
|
||||||
|
outputs.msToFirstChunk = attributes['ai.response.msToFirstChunk']
|
||||||
|
}
|
||||||
|
if (attributes['ai.response.msToFinish']) {
|
||||||
|
outputs.msToFinish = attributes['ai.response.msToFinish']
|
||||||
|
}
|
||||||
|
if (attributes['ai.response.avgCompletionTokensPerSecond']) {
|
||||||
|
outputs.avgCompletionTokensPerSecond = attributes['ai.response.avgCompletionTokensPerSecond']
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用输入提取(回退逻辑)
|
||||||
|
*/
|
||||||
|
private static extractGenericInputs(attributes: Record<string, any>): any {
|
||||||
|
const inputKeys = ['ai.prompt', 'ai.prompt.messages', 'ai.request', 'inputs']
|
||||||
|
|
||||||
|
for (const key of inputKeys) {
|
||||||
|
if (attributes[key]) {
|
||||||
|
return this.parseAttributeValue(attributes[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用输出提取(回退逻辑)
|
||||||
|
*/
|
||||||
|
private static extractGenericOutputs(attributes: Record<string, any>): any {
|
||||||
|
const outputKeys = ['ai.response.text', 'ai.response', 'ai.output', 'outputs']
|
||||||
|
|
||||||
|
for (const key of outputKeys) {
|
||||||
|
if (attributes[key]) {
|
||||||
|
return this.parseAttributeValue(attributes[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析属性值,处理字符串化的 JSON
|
||||||
|
*/
|
||||||
|
private static parseAttributeValue(value: any): any {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value)
|
||||||
|
} catch (e) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化 span 名称,处理 AI SDK 的层级结构
|
||||||
|
*/
|
||||||
|
private static formatSpanName(name: string): string {
|
||||||
|
// AI SDK 的 span 名称可能是 ai.generateText, ai.streamText.doStream 等
|
||||||
|
// 保持原始名称,但可以添加一些格式化逻辑
|
||||||
|
if (name.startsWith('ai.')) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从AI SDK operationId中提取精确的span标签
|
||||||
|
*/
|
||||||
|
private static extractSpanTag(name: string, attributes: Record<string, any>): string {
|
||||||
|
const operationId = attributes['ai.operationId']
|
||||||
|
|
||||||
|
logger.debug('Extracting span tag', {
|
||||||
|
spanName: name,
|
||||||
|
operationId,
|
||||||
|
operationName: attributes['operation.name']
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据AI SDK文档的operationId精确分类
|
||||||
|
switch (operationId) {
|
||||||
|
case 'ai.generateText':
|
||||||
|
return 'LLM-GENERATE'
|
||||||
|
case 'ai.streamText':
|
||||||
|
return 'LLM-STREAM'
|
||||||
|
case 'ai.generateText.doGenerate':
|
||||||
|
return 'PROVIDER-GENERATE'
|
||||||
|
case 'ai.streamText.doStream':
|
||||||
|
return 'PROVIDER-STREAM'
|
||||||
|
case 'ai.toolCall':
|
||||||
|
return 'TOOL-CALL'
|
||||||
|
case 'ai.generateImage':
|
||||||
|
return 'IMAGE'
|
||||||
|
case 'ai.embed':
|
||||||
|
return 'EMBEDDING'
|
||||||
|
default:
|
||||||
|
// 回退逻辑:基于span名称
|
||||||
|
if (name.includes('generateText') || name.includes('streamText')) {
|
||||||
|
return 'LLM'
|
||||||
|
}
|
||||||
|
if (name.includes('generateImage')) {
|
||||||
|
return 'IMAGE'
|
||||||
|
}
|
||||||
|
if (name.includes('embed')) {
|
||||||
|
return 'EMBEDDING'
|
||||||
|
}
|
||||||
|
if (name.includes('toolCall')) {
|
||||||
|
return 'TOOL'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最终回退
|
||||||
|
return attributes['ai.operationType'] || attributes['operation.type'] || 'AI_SDK'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据span类型提取特定的额外数据
|
||||||
|
*/
|
||||||
|
private static extractSpanTypeSpecificData(attributes: Record<string, any>): Record<string, any> {
|
||||||
|
const operationId = attributes['ai.operationId']
|
||||||
|
const specificData: Record<string, any> = {}
|
||||||
|
|
||||||
|
switch (operationId) {
|
||||||
|
case 'ai.generateText':
|
||||||
|
case 'ai.streamText':
|
||||||
|
// LLM顶层spans的特定数据
|
||||||
|
if (attributes['ai.settings.maxOutputTokens']) {
|
||||||
|
specificData.maxOutputTokens = attributes['ai.settings.maxOutputTokens']
|
||||||
|
}
|
||||||
|
if (attributes['resource.name']) {
|
||||||
|
specificData.functionId = attributes['resource.name']
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ai.generateText.doGenerate':
|
||||||
|
case 'ai.streamText.doStream':
|
||||||
|
// Provider spans的特定数据
|
||||||
|
if (attributes['ai.model.id']) {
|
||||||
|
specificData.providerId = attributes['ai.model.provider'] || 'unknown'
|
||||||
|
specificData.modelId = attributes['ai.model.id']
|
||||||
|
}
|
||||||
|
|
||||||
|
// doStream特有的性能数据
|
||||||
|
if (operationId === 'ai.streamText.doStream') {
|
||||||
|
if (attributes['ai.response.msToFirstChunk']) {
|
||||||
|
specificData.msToFirstChunk = attributes['ai.response.msToFirstChunk']
|
||||||
|
}
|
||||||
|
if (attributes['ai.response.msToFinish']) {
|
||||||
|
specificData.msToFinish = attributes['ai.response.msToFinish']
|
||||||
|
}
|
||||||
|
if (attributes['ai.response.avgCompletionTokensPerSecond']) {
|
||||||
|
specificData.tokensPerSecond = attributes['ai.response.avgCompletionTokensPerSecond']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ai.toolCall':
|
||||||
|
// Tool call spans的特定数据
|
||||||
|
specificData.toolName = attributes['ai.toolCall.name']
|
||||||
|
specificData.toolId = attributes['ai.toolCall.id']
|
||||||
|
|
||||||
|
// 根据文档,tool call可能有不同的操作类型
|
||||||
|
if (attributes['operation.name']) {
|
||||||
|
specificData.operationName = attributes['operation.name']
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 通用的AI SDK属性
|
||||||
|
if (attributes['ai.telemetry.functionId']) {
|
||||||
|
specificData.telemetryFunctionId = attributes['ai.telemetry.functionId']
|
||||||
|
}
|
||||||
|
if (attributes['ai.telemetry.metadata']) {
|
||||||
|
specificData.telemetryMetadata = this.parseAttributeValue(attributes['ai.telemetry.metadata'])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加通用的操作标识
|
||||||
|
if (operationId) {
|
||||||
|
specificData.operationType = operationId
|
||||||
|
}
|
||||||
|
if (attributes['operation.name']) {
|
||||||
|
specificData.operationName = attributes['operation.name']
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Extracted type-specific data', {
|
||||||
|
operationId,
|
||||||
|
specificDataKeys: Object.keys(specificData),
|
||||||
|
specificData
|
||||||
|
})
|
||||||
|
|
||||||
|
return specificData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从属性中提取模型名称
|
||||||
|
*/
|
||||||
|
private static extractModelFromAttributes(attributes: Record<string, any>): string | undefined {
|
||||||
|
return (
|
||||||
|
attributes['ai.model.id'] ||
|
||||||
|
attributes['ai.model'] ||
|
||||||
|
attributes['model.id'] ||
|
||||||
|
attributes['model'] ||
|
||||||
|
attributes['modelName']
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤相关的属性,移除不需要的系统属性
|
||||||
|
*/
|
||||||
|
private static filterRelevantAttributes(attributes: Record<string, any>): Record<string, any> {
|
||||||
|
const filtered: Record<string, any> = {}
|
||||||
|
|
||||||
|
// 保留有用的属性,过滤掉已经单独处理的属性
|
||||||
|
const excludeKeys = ['ai.usage', 'ai.prompt', 'ai.response', 'ai.input', 'ai.output', 'inputs', 'outputs']
|
||||||
|
|
||||||
|
Object.entries(attributes).forEach(([key, value]) => {
|
||||||
|
if (!excludeKeys.includes(key)) {
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换 span 状态
|
||||||
|
*/
|
||||||
|
private static convertSpanStatus(statusCode?: SpanStatusCode): string {
|
||||||
|
switch (statusCode) {
|
||||||
|
case SpanStatusCode.OK:
|
||||||
|
return 'OK'
|
||||||
|
case SpanStatusCode.ERROR:
|
||||||
|
return 'ERROR'
|
||||||
|
case SpanStatusCode.UNSET:
|
||||||
|
default:
|
||||||
|
return 'UNSET'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换 span 类型
|
||||||
|
*/
|
||||||
|
private static convertSpanKind(kind?: SpanKind): string {
|
||||||
|
switch (kind) {
|
||||||
|
case SpanKind.INTERNAL:
|
||||||
|
return 'INTERNAL'
|
||||||
|
case SpanKind.CLIENT:
|
||||||
|
return 'CLIENT'
|
||||||
|
case SpanKind.SERVER:
|
||||||
|
return 'SERVER'
|
||||||
|
case SpanKind.PRODUCER:
|
||||||
|
return 'PRODUCER'
|
||||||
|
case SpanKind.CONSUMER:
|
||||||
|
return 'CONSUMER'
|
||||||
|
default:
|
||||||
|
return 'INTERNAL'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换时间戳格式
|
||||||
|
*/
|
||||||
|
private static convertTimestamp(timestamp: [number, number] | number): number {
|
||||||
|
if (Array.isArray(timestamp)) {
|
||||||
|
// OpenTelemetry 高精度时间戳 [seconds, nanoseconds]
|
||||||
|
return timestamp[0] * 1000 + timestamp[1] / 1000000
|
||||||
|
}
|
||||||
|
return timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,7 +15,9 @@ const MessageContent: React.FC<Props> = ({ message }) => {
|
|||||||
<>
|
<>
|
||||||
{!isEmpty(message.mentions) && (
|
{!isEmpty(message.mentions) && (
|
||||||
<Flex gap="8px" wrap style={{ marginBottom: '10px' }}>
|
<Flex gap="8px" wrap style={{ marginBottom: '10px' }}>
|
||||||
{message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)}
|
{message.mentions?.map((model) => (
|
||||||
|
<MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>
|
||||||
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
<MessageBlockRenderer blocks={message.blocks} message={message} />
|
<MessageBlockRenderer blocks={message.blocks} message={message} />
|
||||||
|
|||||||
@ -381,7 +381,8 @@ export async function fetchChatCompletion({
|
|||||||
messages,
|
messages,
|
||||||
assistant,
|
assistant,
|
||||||
options,
|
options,
|
||||||
onChunkReceived
|
onChunkReceived,
|
||||||
|
topicId
|
||||||
}: {
|
}: {
|
||||||
messages: StreamTextParams['messages']
|
messages: StreamTextParams['messages']
|
||||||
assistant: Assistant
|
assistant: Assistant
|
||||||
@ -391,10 +392,18 @@ export async function fetchChatCompletion({
|
|||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
}
|
}
|
||||||
onChunkReceived: (chunk: Chunk) => void
|
onChunkReceived: (chunk: Chunk) => void
|
||||||
|
topicId?: string // 添加 topicId 参数
|
||||||
// TODO
|
// TODO
|
||||||
// onChunkStatus: (status: 'searching' | 'processing' | 'success' | 'error') => void
|
// onChunkStatus: (status: 'searching' | 'processing' | 'success' | 'error') => void
|
||||||
}) {
|
}) {
|
||||||
console.log('fetchChatCompletion', messages, assistant)
|
logger.info('fetchChatCompletion called with detailed context', {
|
||||||
|
messageCount: messages?.length || 0,
|
||||||
|
assistantId: assistant.id,
|
||||||
|
topicId,
|
||||||
|
hasTopicId: !!topicId,
|
||||||
|
modelId: assistant.model?.id,
|
||||||
|
modelName: assistant.model?.name
|
||||||
|
})
|
||||||
|
|
||||||
const AI = new AiProviderNew(assistant.model || getDefaultModel())
|
const AI = new AiProviderNew(assistant.model || getDefaultModel())
|
||||||
const provider = AI.getActualProvider()
|
const provider = AI.getActualProvider()
|
||||||
@ -446,15 +455,57 @@ export async function fetchChatCompletion({
|
|||||||
isImageGenerationEndpoint: isDedicatedImageGenerationModel(assistant.model || getDefaultModel()),
|
isImageGenerationEndpoint: isDedicatedImageGenerationModel(assistant.model || getDefaultModel()),
|
||||||
enableWebSearch: capabilities.enableWebSearch,
|
enableWebSearch: capabilities.enableWebSearch,
|
||||||
enableGenerateImage: capabilities.enableGenerateImage,
|
enableGenerateImage: capabilities.enableGenerateImage,
|
||||||
mcpTools,
|
mcpTools
|
||||||
assistant
|
|
||||||
}
|
}
|
||||||
// if (capabilities.enableWebSearch) {
|
// if (capabilities.enableWebSearch) {
|
||||||
// onChunkReceived({ type: ChunkType.LLM_WEB_SEARCH_IN_PROGRESS })
|
// onChunkReceived({ type: ChunkType.LLM_WEB_SEARCH_IN_PROGRESS })
|
||||||
// }
|
// }
|
||||||
// --- Call AI Completions ---
|
// --- Call AI Completions ---
|
||||||
onChunkReceived({ type: ChunkType.LLM_RESPONSE_CREATED })
|
onChunkReceived({ type: ChunkType.LLM_RESPONSE_CREATED })
|
||||||
await AI.completions(modelId, aiSdkParams, middlewareConfig)
|
|
||||||
|
// 在 AI SDK 调用时设置正确的 OpenTelemetry 上下文
|
||||||
|
if (topicId) {
|
||||||
|
logger.info('Attempting to set OpenTelemetry context', { topicId })
|
||||||
|
const { currentSpan } = await import('@renderer/services/SpanManagerService')
|
||||||
|
|
||||||
|
const parentSpan = currentSpan(topicId, modelId)
|
||||||
|
logger.info('Parent span lookup result', {
|
||||||
|
topicId,
|
||||||
|
hasParentSpan: !!parentSpan,
|
||||||
|
parentSpanId: parentSpan?.spanContext().spanId,
|
||||||
|
parentTraceId: parentSpan?.spanContext().traceId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (parentSpan) {
|
||||||
|
logger.info('Found parent span, using completionsForTrace for proper span hierarchy', {
|
||||||
|
topicId,
|
||||||
|
parentSpanId: parentSpan.spanContext().spanId,
|
||||||
|
parentTraceId: parentSpan.spanContext().traceId
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.warn('No parent span found for topicId, using completionsForTrace anyway', { topicId })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用带trace支持的completions方法,它会自动创建子span并关联到父span
|
||||||
|
await AI.completionsForTrace(modelId, aiSdkParams, {
|
||||||
|
...middlewareConfig,
|
||||||
|
assistant,
|
||||||
|
topicId,
|
||||||
|
callType: 'chat'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.warn('No topicId provided, using regular completions')
|
||||||
|
// 没有topicId时,禁用telemetry以避免警告
|
||||||
|
const configWithoutTelemetry = {
|
||||||
|
...middlewareConfig,
|
||||||
|
topicId: undefined // 确保telemetryPlugin不会尝试查找span
|
||||||
|
}
|
||||||
|
await AI.completions(modelId, aiSdkParams, {
|
||||||
|
...configWithoutTelemetry,
|
||||||
|
assistant,
|
||||||
|
callType: 'chat'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// await AI.completions(
|
// await AI.completions(
|
||||||
// {
|
// {
|
||||||
@ -640,7 +691,7 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const AI = new AiProvider(provider)
|
const AI = new AiProviderNew(model)
|
||||||
|
|
||||||
const topicId = messages?.find((message) => message.topicId)?.topicId || undefined
|
const topicId = messages?.find((message) => message.topicId)?.topicId || undefined
|
||||||
|
|
||||||
@ -665,28 +716,63 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
|
|||||||
})
|
})
|
||||||
const conversation = JSON.stringify(structredMessages)
|
const conversation = JSON.stringify(structredMessages)
|
||||||
|
|
||||||
// 复制 assistant 对象,并强制关闭思考预算
|
// // 复制 assistant 对象,并强制关闭思考预算
|
||||||
|
// const summaryAssistant = {
|
||||||
|
// ...assistant,
|
||||||
|
// settings: {
|
||||||
|
// ...assistant.settings,
|
||||||
|
// reasoning_effort: undefined,
|
||||||
|
// qwenThinkMode: false
|
||||||
|
// }
|
||||||
|
// }
|
||||||
const summaryAssistant = {
|
const summaryAssistant = {
|
||||||
...assistant,
|
...assistant,
|
||||||
settings: {
|
settings: {
|
||||||
...assistant.settings,
|
...assistant.settings,
|
||||||
reasoning_effort: undefined,
|
reasoning_effort: undefined,
|
||||||
qwenThinkMode: false
|
qwenThinkMode: false
|
||||||
}
|
},
|
||||||
|
prompt,
|
||||||
|
model
|
||||||
|
}
|
||||||
|
// const params: CompletionsParams = {
|
||||||
|
// callType: 'summary',
|
||||||
|
// messages: conversation,
|
||||||
|
// assistant: { ...summaryAssistant, prompt, model },
|
||||||
|
// maxTokens: 1000,
|
||||||
|
// streamOutput: false,
|
||||||
|
// topicId,
|
||||||
|
// enableReasoning: false
|
||||||
|
// }
|
||||||
|
const llmMessages = {
|
||||||
|
system: prompt,
|
||||||
|
prompt: conversation
|
||||||
}
|
}
|
||||||
|
|
||||||
const params: CompletionsParams = {
|
// const llmMessages = await ConversationService.prepareMessagesForModel(messages, summaryAssistant)
|
||||||
callType: 'summary',
|
|
||||||
messages: conversation,
|
// 使用 transformParameters 模块构建参数
|
||||||
assistant: { ...summaryAssistant, prompt, model },
|
// const { params: aiSdkParams, modelId } = await buildStreamTextParams(llmMessages, summaryAssistant, provider)
|
||||||
maxTokens: 1000,
|
|
||||||
|
const middlewareConfig: AiSdkMiddlewareConfig = {
|
||||||
streamOutput: false,
|
streamOutput: false,
|
||||||
topicId,
|
enableReasoning: false,
|
||||||
enableReasoning: false
|
isPromptToolUse: false,
|
||||||
|
isSupportedToolUse: false,
|
||||||
|
isImageGenerationEndpoint: false,
|
||||||
|
enableWebSearch: false,
|
||||||
|
enableGenerateImage: false,
|
||||||
|
mcpTools: []
|
||||||
}
|
}
|
||||||
|
console.log('fetchMessagesSummary', '开始总结')
|
||||||
try {
|
try {
|
||||||
const { getText } = await AI.completionsForTrace(params)
|
const { getText } = await AI.completionsForTrace(model.id, llmMessages, {
|
||||||
|
...middlewareConfig,
|
||||||
|
assistant: summaryAssistant,
|
||||||
|
topicId,
|
||||||
|
callType: 'summary'
|
||||||
|
})
|
||||||
|
console.log('fetchMessagesSummary', '总结完成', getText())
|
||||||
const text = getText()
|
const text = getText()
|
||||||
return removeSpecialCharactersForTopicName(text) || null
|
return removeSpecialCharactersForTopicName(text) || null
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export interface OrchestrationRequest {
|
|||||||
timeout?: number
|
timeout?: number
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
topicId?: string // 添加 topicId 用于 trace
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -45,7 +46,8 @@ export class OrchestrationService {
|
|||||||
messages: llmMessages,
|
messages: llmMessages,
|
||||||
assistant: assistant,
|
assistant: assistant,
|
||||||
options: request.options,
|
options: request.options,
|
||||||
onChunkReceived
|
onChunkReceived,
|
||||||
|
topicId: request.topicId
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
onChunkReceived({ type: ChunkType.ERROR, error })
|
onChunkReceived({ type: ChunkType.ERROR, error })
|
||||||
@ -67,7 +69,8 @@ export async function transformMessagesAndFetch(
|
|||||||
messages: llmMessages,
|
messages: llmMessages,
|
||||||
assistant: assistant,
|
assistant: assistant,
|
||||||
options: request.options,
|
options: request.options,
|
||||||
onChunkReceived
|
onChunkReceived,
|
||||||
|
topicId: request.topicId
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
onChunkReceived({ type: ChunkType.ERROR, error })
|
onChunkReceived({ type: ChunkType.ERROR, error })
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {})
|
|||||||
return (chunk: Chunk) => {
|
return (chunk: Chunk) => {
|
||||||
try {
|
try {
|
||||||
const data = chunk
|
const data = chunk
|
||||||
logger.debug('data: ', data)
|
// logger.debug('data: ', data)
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case ChunkType.BLOCK_COMPLETE: {
|
case ChunkType.BLOCK_COMPLETE: {
|
||||||
if (callbacks.onComplete) callbacks.onComplete(AssistantMessageStatus.SUCCESS, data?.response)
|
if (callbacks.onComplete) callbacks.onComplete(AssistantMessageStatus.SUCCESS, data?.response)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { convertSpanToSpanEntity, FunctionSpanExporter, FunctionSpanProcessor } from '@mcp-trace/trace-core'
|
import { convertSpanToSpanEntity, FunctionSpanExporter, FunctionSpanProcessor } from '@mcp-trace/trace-core'
|
||||||
import { WebTracer } from '@mcp-trace/trace-web'
|
import { WebTracer } from '@mcp-trace/trace-web'
|
||||||
|
import { trace } from '@opentelemetry/api'
|
||||||
import { ReadableSpan } from '@opentelemetry/sdk-trace-base'
|
import { ReadableSpan } from '@opentelemetry/sdk-trace-base'
|
||||||
|
|
||||||
const logger = loggerService.withContext('WebTraceService')
|
const logger = loggerService.withContext('WebTraceService')
|
||||||
@ -33,6 +34,10 @@ class WebTraceService {
|
|||||||
processor
|
processor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTracer() {
|
||||||
|
return trace.getTracer(TRACER_NAME, '1.0.0')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const webTraceService = new WebTraceService()
|
export const webTraceService = new WebTraceService()
|
||||||
|
|||||||
@ -896,6 +896,7 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
{
|
{
|
||||||
messages: messagesForContext,
|
messages: messagesForContext,
|
||||||
assistant,
|
assistant,
|
||||||
|
topicId,
|
||||||
options: {
|
options: {
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user