feat: Support LLM Tracing by Alibaba Cloud EDAS product (#7895)

* feat: add tracing modules

* Initial commit

* fix: problem

* fix: update trace web

* fix: trace view

* fix: trace view

* fix: fix some problem

* fix: knowledge and mcp trace

* feat: save trace to user home dir

* feat: open trace with electron browser window

* fix: root trace outputs

* feat: trace internationalization and add trace icon

* feat: add trace title

* feat: update

* package.json添加windows运行script

* feat: update window title

* fix: mcp trace param

* fix: error show

* fix: listTool result

* fix: merge error

* feat: add stream usage and response

* feat: change trace stream

* fix: change stream adapter

* fix: span detail show problem

* fix: process show by time

* fix: stream outputs

* fix: merge problem

* fix: stream outputs

* fix: output text

* fix: EDAS support text

* fix: change trace footer style

* fix: topicId is loaded multiple times

* fix: span reload problem & attribute with cache

* fix: refresh optimization

* Change Powered by text.

* resolve upstream conflicts

* fix: build-time type exception

* fix: exceptions not used when building

* fix: recend no trace

* fix: resend trace list

* fix: delete temporary files

* feat: trace for resend

* fix: trace for resend message with edit

* fix: directory structure and construction method of mcp-trace

* fix: change CRLF to LF

* fix: add function call outputs

* Revert "fix: change CRLF to LF"

* fix: reorganize multi-model display

* fix: append model trace binding topic

* fix: some problems

* fix: code optimization

* fix: delete async

* fix: UI optimization

* fix: sort import

---------

Co-authored-by: 崔顺发 <csf01409784@alibaba-inc.com>
Co-authored-by: 管鑫荣 <gxr01409783@alibaba-inc.com>
This commit is contained in:
alickreborn0 2025-07-20 06:53:35 +00:00 committed by GitHub
parent 411c5bc94e
commit 3b123863b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
79 changed files with 4982 additions and 239 deletions

1
.gitignore vendored
View File

@ -35,6 +35,7 @@ Thumbs.db
node_modules node_modules
dist dist
out out
mcp_server
stats.html stats.html
# ENV # ENV

View File

@ -19,7 +19,9 @@ export default defineConfig({
'@main': resolve('src/main'), '@main': resolve('src/main'),
'@types': resolve('src/renderer/src/types'), '@types': resolve('src/renderer/src/types'),
'@shared': resolve('packages/shared'), '@shared': resolve('packages/shared'),
'@logger': resolve('src/main/services/LoggerService') '@logger': resolve('src/main/services/LoggerService'),
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
'@mcp-trace/trace-node': resolve('packages/mcp-trace/trace-node')
} }
}, },
build: { build: {
@ -40,10 +42,16 @@ export default defineConfig({
} }
}, },
preload: { preload: {
plugins: [externalizeDepsPlugin()], plugins: [
react({
tsDecorators: true
}),
externalizeDepsPlugin()
],
resolve: { resolve: {
alias: { alias: {
'@shared': resolve('packages/shared') '@shared': resolve('packages/shared'),
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core')
} }
}, },
build: { build: {
@ -53,6 +61,7 @@ export default defineConfig({
renderer: { renderer: {
plugins: [ plugins: [
react({ react({
tsDecorators: true,
plugins: [ plugins: [
[ [
'@swc/plugin-styled-components', '@swc/plugin-styled-components',
@ -72,7 +81,9 @@ export default defineConfig({
alias: { alias: {
'@renderer': resolve('src/renderer/src'), '@renderer': resolve('src/renderer/src'),
'@shared': resolve('packages/shared'), '@shared': resolve('packages/shared'),
'@logger': resolve('src/renderer/src/services/LoggerService') '@logger': resolve('src/renderer/src/services/LoggerService'),
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web')
} }
}, },
optimizeDeps: { optimizeDeps: {
@ -91,7 +102,8 @@ export default defineConfig({
index: resolve(__dirname, 'src/renderer/index.html'), index: resolve(__dirname, 'src/renderer/index.html'),
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'), miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'),
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'), selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html') selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
} }
} }
}, },

View File

@ -13,7 +13,10 @@
], ],
"installConfig": { "installConfig": {
"hoistingLimits": [ "hoistingLimits": [
"packages/database" "packages/database",
"packages/mcp-trace/trace-core",
"packages/mcp-trace/trace-node",
"packages/mcp-trace/trace-web"
] ]
} }
}, },
@ -38,6 +41,7 @@
"publish": "yarn build:check && yarn release patch push", "publish": "yarn build:check && yarn release patch push",
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -", "pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
"generate:agents": "yarn workspace @cherry-studio/database agents", "generate:agents": "yarn workspace @cherry-studio/database agents",
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build", "analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=true yarn build", "analyze:main": "VISUALIZER_MAIN=true yarn build",
"typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck": "npm run typecheck:node && npm run typecheck:web",
@ -74,6 +78,7 @@
"notion-helper": "^1.3.22", "notion-helper": "^1.3.22",
"os-proxy-config": "^1.1.2", "os-proxy-config": "^1.1.2",
"pdfjs-dist": "4.10.38", "pdfjs-dist": "4.10.38",
"react-json-view": "^1.21.3",
"selection-hook": "^1.0.7", "selection-hook": "^1.0.7",
"turndown": "7.2.0" "turndown": "7.2.0"
}, },
@ -114,6 +119,12 @@
"@modelcontextprotocol/sdk": "^1.12.3", "@modelcontextprotocol/sdk": "^1.12.3",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15", "@notionhq/client": "^2.2.15",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-node": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@playwright/test": "^1.52.0", "@playwright/test": "^1.52.0",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.7.0", "@shikijs/markdown-it": "^3.7.0",
@ -195,7 +206,7 @@
"mime": "^4.0.4", "mime": "^4.0.4",
"motion": "^12.10.5", "motion": "^12.10.5",
"npx-scope-finder": "^1.2.0", "npx-scope-finder": "^1.2.0",
"officeparser": "^4.1.1", "officeparser": "^4.2.0",
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch", "openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
"p-queue": "^8.1.0", "p-queue": "^8.1.0",
"playwright": "^1.52.0", "playwright": "^1.52.0",
@ -216,6 +227,7 @@
"react-window": "^1.8.11", "react-window": "^1.8.11",
"redux": "^5.0.1", "redux": "^5.0.1",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"reflect-metadata": "0.2.2",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.1.0", "rehype-mathjax": "^7.1.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",

View File

@ -0,0 +1,26 @@
import { SpanKind, SpanStatusCode } from '@opentelemetry/api'
import { ReadableSpan } from '@opentelemetry/sdk-trace-base'
import { SpanEntity } from '../types/config'
/**
* convert ReadableSpan to SpanEntity
* @param span ReadableSpan
* @returns SpanEntity
*/
export function convertSpanToSpanEntity(span: ReadableSpan): SpanEntity {
return {
id: span.spanContext().spanId,
traceId: span.spanContext().traceId,
parentId: span.parentSpanContext?.spanId || '',
name: span.name,
startTime: span.startTime[0] * 1e3 + Math.floor(span.startTime[1] / 1e6), // 转为毫秒
endTime: span.endTime ? span.endTime[0] * 1e3 + Math.floor(span.endTime[1] / 1e6) : undefined, // 转为毫秒
attributes: { ...span.attributes },
status: SpanStatusCode[span.status.code],
events: span.events,
kind: SpanKind[span.kind],
links: span.links,
modelName: span.attributes?.modelName
} as SpanEntity
}

View File

@ -0,0 +1,7 @@
import { ReadableSpan } from '@opentelemetry/sdk-trace-base'
export interface TraceCache {
createSpan: (span: ReadableSpan) => void
endSpan: (span: ReadableSpan) => void
clear: () => void
}

View File

@ -0,0 +1,163 @@
import 'reflect-metadata'
import { SpanStatusCode, trace } from '@opentelemetry/api'
import { context as traceContext } from '@opentelemetry/api'
import { defaultConfig } from '../types/config'
export interface SpanDecoratorOptions {
spanName?: string
traceName?: string
tag?: string
}
export function TraceMethod(traced: SpanDecoratorOptions) {
return function (target: any, propertyKey?: any, descriptor?: PropertyDescriptor | undefined) {
// 兼容静态方法装饰器只传2个参数的情况
if (!descriptor) {
descriptor = Object.getOwnPropertyDescriptor(target, propertyKey)
}
if (!descriptor || typeof descriptor.value !== 'function') {
throw new Error('TraceMethod can only be applied to methods.')
}
const originalMethod = descriptor.value
const traceName = traced.traceName || defaultConfig.defaultTracerName || 'default'
const tracer = trace.getTracer(traceName)
descriptor.value = function (...args: any[]) {
const name = traced.spanName || propertyKey
return tracer.startActiveSpan(name, async (span) => {
try {
span.setAttribute('inputs', convertToString(args))
span.setAttribute('tags', traced.tag || '')
const result = await originalMethod.apply(this, args)
span.setAttribute('outputs', convertToString(result))
span.setStatus({ code: SpanStatusCode.OK })
return result
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
span.setStatus({
code: SpanStatusCode.ERROR,
message: err.message
})
span.recordException(err)
throw error
} finally {
span.end()
}
})
}
return descriptor
}
}
export function TraceProperty(traced: SpanDecoratorOptions) {
return (target: any, propertyKey: string, descriptor?: PropertyDescriptor) => {
// 处理箭头函数类属性
const traceName = traced.traceName || defaultConfig.defaultTracerName || 'default'
const tracer = trace.getTracer(traceName)
const name = traced.spanName || propertyKey
if (!descriptor) {
const originalValue = target[propertyKey]
Object.defineProperty(target, propertyKey, {
value: async function (...args: any[]) {
const span = tracer.startSpan(name)
try {
span.setAttribute('inputs', convertToString(args))
span.setAttribute('tags', traced.tag || '')
const result = await originalValue.apply(this, args)
span.setAttribute('outputs', convertToString(result))
return result
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
span.recordException(err)
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
throw error
} finally {
span.end()
}
},
configurable: true,
writable: true
})
return
}
// 标准方法装饰器逻辑
const originalMethod = descriptor.value
descriptor.value = async function (...args: any[]) {
const span = tracer.startSpan(name)
try {
span.setAttribute('inputs', convertToString(args))
span.setAttribute('tags', traced.tag || '')
const result = await originalMethod.apply(this, args)
span.setAttribute('outputs', convertToString(result))
return result
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
span.recordException(err)
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
throw error
} finally {
span.end()
}
}
}
}
export function withSpanFunc<F extends (...args: any[]) => any>(
name: string,
tag: string,
fn: F,
args: Parameters<F>
): ReturnType<F> {
const traceName = defaultConfig.defaultTracerName || 'default'
const tracer = trace.getTracer(traceName)
const _name = name || fn.name || 'anonymousFunction'
return traceContext.with(traceContext.active(), () =>
tracer.startActiveSpan(
_name,
{
attributes: {
tags: tag || '',
inputs: JSON.stringify(args)
}
},
(span) => {
// 在这里调用原始函数
const result = fn(...args)
if (result instanceof Promise) {
return result
.then((res) => {
span.setStatus({ code: SpanStatusCode.OK })
span.setAttribute('outputs', convertToString(res))
return res
})
.catch((error) => {
const err = error instanceof Error ? error : new Error(String(error))
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
span.recordException(err)
throw error
})
.finally(() => span.end())
} else {
span.setStatus({ code: SpanStatusCode.OK })
span.setAttribute('outputs', convertToString(result))
span.end()
}
return result
}
)
)
}
function convertToString(args: any | any[]): string | boolean | number {
if (typeof args === 'string' || typeof args === 'boolean' || typeof args === 'number') {
return args
}
return JSON.stringify(args)
}

View File

@ -0,0 +1,26 @@
import { ExportResult, ExportResultCode } from '@opentelemetry/core'
import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'
export type SaveFunction = (spans: ReadableSpan[]) => Promise<void>
export class FunctionSpanExporter implements SpanExporter {
private exportFunction: SaveFunction
constructor(fn: SaveFunction) {
this.exportFunction = fn
}
shutdown(): Promise<void> {
return Promise.resolve()
}
export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
this.exportFunction(spans)
.then(() => {
resultCallback({ code: ExportResultCode.SUCCESS })
})
.catch((error) => {
resultCallback({ code: ExportResultCode.FAILED, error: error })
})
}
}

View File

@ -0,0 +1,8 @@
export * from './core/spanConvert'
export * from './core/traceCache'
export * from './core/traceMethod'
export * from './exporters/FuncSpanExporter'
export * from './processors/CacheSpanProcessor'
export * from './processors/EmitterSpanProcessor'
export * from './processors/FuncSpanProcessor'
export * from './types/config'

View File

@ -0,0 +1,40 @@
import { Context, trace } from '@opentelemetry/api'
import { BatchSpanProcessor, BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base'
import { TraceCache } from '../core/traceCache'
export class CacheBatchSpanProcessor extends BatchSpanProcessor {
private cache: TraceCache
constructor(_exporter: SpanExporter, cache: TraceCache, config?: BufferConfig) {
super(_exporter, config)
this.cache = cache
}
override onEnd(span: ReadableSpan): void {
super.onEnd(span)
this.cache.endSpan(span)
}
override onStart(span: Span, parentContext: Context): void {
super.onStart(span, parentContext)
this.cache.createSpan({
name: span.name,
kind: span.kind,
spanContext: () => span.spanContext(),
parentSpanContext: trace.getSpanContext(parentContext),
startTime: span.startTime,
status: span.status,
attributes: span.attributes,
links: span.links,
events: span.events,
duration: span.duration,
ended: span.ended,
resource: span.resource,
instrumentationScope: span.instrumentationScope,
droppedAttributesCount: span.droppedAttributesCount,
droppedEventsCount: span.droppedEventsCount,
droppedLinksCount: span.droppedLinksCount
} as ReadableSpan)
}
}

View File

@ -0,0 +1,28 @@
import { Context } from '@opentelemetry/api'
import { BatchSpanProcessor, BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base'
import { EventEmitter } from 'stream'
import { convertSpanToSpanEntity } from '../core/spanConvert'
export const TRACE_DATA_EVENT = 'trace_data_event'
export const ON_START = 'start'
export const ON_END = 'end'
export class EmitterSpanProcessor extends BatchSpanProcessor {
private emitter: EventEmitter
constructor(_exporter: SpanExporter, emitter: NodeJS.EventEmitter, config?: BufferConfig) {
super(_exporter, config)
this.emitter = emitter
}
override onEnd(span: ReadableSpan): void {
super.onEnd(span)
this.emitter.emit(TRACE_DATA_EVENT, ON_END, convertSpanToSpanEntity(span))
}
override onStart(span: Span, parentContext: Context): void {
super.onStart(span, parentContext)
this.emitter.emit(TRACE_DATA_EVENT, ON_START, convertSpanToSpanEntity(span))
}
}

View File

@ -0,0 +1,42 @@
import { Context, trace } from '@opentelemetry/api'
import { BatchSpanProcessor, BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base'
export type SpanFunction = (span: ReadableSpan) => void
export class FunctionSpanProcessor extends BatchSpanProcessor {
private start: SpanFunction
private end: SpanFunction
constructor(_exporter: SpanExporter, start: SpanFunction, end: SpanFunction, config?: BufferConfig) {
super(_exporter, config)
this.start = start
this.end = end
}
override onEnd(span: ReadableSpan): void {
super.onEnd(span)
this.end(span)
}
override onStart(span: Span, parentContext: Context): void {
super.onStart(span, parentContext)
this.start({
name: span.name,
kind: span.kind,
spanContext: () => span.spanContext(),
parentSpanContext: trace.getSpanContext(parentContext),
startTime: span.startTime,
status: span.status,
attributes: span.attributes,
links: span.links,
events: span.events,
duration: span.duration,
ended: span.ended,
resource: span.resource,
instrumentationScope: span.instrumentationScope,
droppedAttributesCount: span.droppedAttributesCount,
droppedEventsCount: span.droppedEventsCount,
droppedLinksCount: span.droppedLinksCount
} as ReadableSpan)
}
}

View File

@ -0,0 +1,67 @@
import { Link } from '@opentelemetry/api'
import { TimedEvent } from '@opentelemetry/sdk-trace-base'
export type AttributeValue =
| string
| number
| boolean
| Array<null | undefined | string>
| Array<null | undefined | number>
| Array<null | undefined | boolean>
| { [key: string]: string | number | boolean }
| Array<null | undefined | { [key: string]: string | number | boolean }>
export type Attributes = {
[key: string]: AttributeValue
}
export interface TelemetryConfig {
serviceName: string
endpoint?: string
headers?: Record<string, string>
defaultTracerName?: string
isDevModel?: boolean
}
export interface TraceConfig extends TelemetryConfig {
maxAttributesPerSpan?: number
}
export interface TraceEntity {
id: string
name: string
}
export interface TokenUsage {
prompt_tokens: number
completion_tokens: number
total_tokens: number
prompt_tokens_details?: {
[key: string]: number
}
}
export interface SpanEntity {
id: string
name: string
parentId: string
traceId: string
status: string
kind: string
attributes: Attributes | undefined
isEnd: boolean
events: TimedEvent[] | undefined
startTime: number
endTime: number | null
links: Link[] | undefined
topicId?: string
usage?: TokenUsage
modelName?: string
}
export const defaultConfig: TelemetryConfig = {
serviceName: 'default',
headers: {},
defaultTracerName: 'default',
isDevModel: true
}

View File

@ -0,0 +1,46 @@
import { trace, Tracer } from '@opentelemetry/api'
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'
import { W3CTraceContextPropagator } from '@opentelemetry/core'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { BatchSpanProcessor, ConsoleSpanExporter, SpanProcessor } from '@opentelemetry/sdk-trace-base'
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
import { defaultConfig, TraceConfig } from '../trace-core/types/config'
export class NodeTracer {
private static provider: NodeTracerProvider
private static defaultTracer: Tracer
private static spanProcessor: SpanProcessor
static init(config?: TraceConfig, spanProcessor?: SpanProcessor) {
if (config) {
defaultConfig.serviceName = config.serviceName || defaultConfig.serviceName
defaultConfig.endpoint = config.endpoint || defaultConfig.endpoint
defaultConfig.headers = config.headers || defaultConfig.headers
defaultConfig.defaultTracerName = config.defaultTracerName || defaultConfig.defaultTracerName
}
this.spanProcessor = spanProcessor || new BatchSpanProcessor(this.getExporter())
this.provider = new NodeTracerProvider({
spanProcessors: [this.spanProcessor]
})
this.provider.register({
propagator: new W3CTraceContextPropagator(),
contextManager: new AsyncLocalStorageContextManager()
})
this.defaultTracer = trace.getTracer(config?.defaultTracerName || 'default')
}
private static getExporter(config?: TraceConfig) {
if (config && config.endpoint) {
return new OTLPTraceExporter({
url: `${config.endpoint}/v1/traces`,
headers: config.headers || undefined
})
}
return new ConsoleSpanExporter()
}
public static getTracer() {
return this.defaultTracer
}
}

View File

@ -0,0 +1,75 @@
import { Context, ContextManager, ROOT_CONTEXT } from '@opentelemetry/api'
export class TopicContextManager implements ContextManager {
private topicContextStack: Map<string, Context[]>
private _topicContexts: Map<string, Context>
constructor() {
// topicId -> context
this.topicContextStack = new Map()
this._topicContexts = new Map()
}
// 绑定一个context到topicId
startContextForTopic(topicId, context: Context) {
const currentContext = this.getCurrentContext(topicId)
this._topicContexts.set(topicId, context)
if (!this.topicContextStack.has(topicId) && !this.topicContextStack.get(topicId)) {
this.topicContextStack.set(topicId, [currentContext])
} else {
this.topicContextStack.get(topicId)?.push(currentContext)
}
}
// 获取topicId对应的context
getContextForTopic(topicId) {
return this.getCurrentContext(topicId)
}
endContextForTopic(topicId) {
const context = this.getHistoryContext(topicId)
this._topicContexts.set(topicId, context)
}
cleanContextForTopic(topicId) {
this.topicContextStack.delete(topicId)
this._topicContexts.delete(topicId)
}
private getHistoryContext(topicId): Context {
const hasContext = this.topicContextStack.has(topicId) && this.topicContextStack.get(topicId)
const context = hasContext && hasContext.length > 0 && hasContext.pop()
return context ? context : ROOT_CONTEXT
}
private getCurrentContext(topicId): Context {
const hasContext = this._topicContexts.has(topicId) && this._topicContexts.get(topicId)
return hasContext || ROOT_CONTEXT
}
// OpenTelemetry接口实现
active() {
// 不支持全局active必须显式传递
return ROOT_CONTEXT
}
with(_, fn, thisArg, ...args) {
// 直接调用fn不做全局active切换
return fn.apply(thisArg, args)
}
bind(target, context) {
// 显式绑定
target.__ot_context = context
return target
}
enable() {
return this
}
disable() {
this._topicContexts.clear()
return this
}
}

View File

@ -0,0 +1,3 @@
export * from './TopicContextManager'
export * from './traceContextPromise'
export * from './webTracer'

View File

@ -0,0 +1,99 @@
import { Context, context } from '@opentelemetry/api'
const originalPromise = globalThis.Promise
class TraceContextPromise<T> extends Promise<T> {
_context: Context
constructor(
executor: (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void,
ctx?: Context
) {
const capturedContext = ctx || context.active()
super((resolve, reject) => {
context.with(capturedContext, () => {
executor(
(value) => context.with(capturedContext, () => resolve(value)),
(reason) => context.with(capturedContext, () => reject(reason))
)
})
})
this._context = capturedContext
}
// 兼容 Promise.resolve/reject
static resolve(): Promise<void>
static resolve<T>(value: T | PromiseLike<T>): Promise<T>
static resolve<T>(value: T | PromiseLike<T>, ctx?: Context): Promise<T>
static resolve<T>(value?: T | PromiseLike<T>, ctx?: Context): Promise<T | void> {
return new TraceContextPromise<T | void>((resolve) => resolve(value as T), ctx)
}
static reject<T = never>(reason?: any): Promise<T>
static reject<T = never>(reason?: any, ctx?: Context): Promise<T> {
return new TraceContextPromise<T>((_, reject) => reject(reason), ctx)
}
static all<T>(values: (T | PromiseLike<T>)[]): Promise<T[]> {
// 尝试从缓存获取 context
let capturedContext = context.active()
const newValues = values.map((v) => {
if (v instanceof Promise && !(v instanceof TraceContextPromise)) {
return new TraceContextPromise((resolve, reject) => v.then(resolve, reject), capturedContext)
} else if (typeof v === 'function') {
// 如果 v 是一个 Function使用 context 传递 trace 上下文
return (...args: any[]) => context.with(capturedContext, () => v(...args))
} else {
return v
}
})
if (Array.isArray(values) && values.length > 0 && values[0] instanceof TraceContextPromise) {
capturedContext = (values[0] as TraceContextPromise<any>)._context
}
return originalPromise.all(newValues) as Promise<T[]>
}
static race<T>(values: (T | PromiseLike<T>)[]): Promise<T> {
const capturedContext = context.active()
return new TraceContextPromise<T>((resolve, reject) => {
originalPromise.race(values).then(
(result) => context.with(capturedContext, () => resolve(result)),
(err) => context.with(capturedContext, () => reject(err))
)
}, capturedContext)
}
static allSettled<T>(values: (T | PromiseLike<T>)[]): Promise<PromiseSettledResult<T>[]> {
const capturedContext = context.active()
return new TraceContextPromise<PromiseSettledResult<T>[]>((resolve, reject) => {
originalPromise.allSettled(values).then(
(result) => context.with(capturedContext, () => resolve(result)),
(err) => context.with(capturedContext, () => reject(err))
)
}, capturedContext)
}
static any<T>(values: (T | PromiseLike<T>)[]): Promise<T> {
const capturedContext = context.active()
return new TraceContextPromise<T>((resolve, reject) => {
originalPromise.any(values).then(
(result) => context.with(capturedContext, () => resolve(result)),
(err) => context.with(capturedContext, () => reject(err))
)
}, capturedContext)
}
}
/**
* TraceContextPromise Promise
*/
export function instrumentPromises() {
globalThis.Promise = TraceContextPromise as unknown as PromiseConstructor
}
/**
* Promise
*/
export function uninstrumentPromises() {
globalThis.Promise = originalPromise
}

View File

@ -0,0 +1,46 @@
import { W3CTraceContextPropagator } from '@opentelemetry/core'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { BatchSpanProcessor, ConsoleSpanExporter, SpanProcessor } from '@opentelemetry/sdk-trace-base'
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'
import { defaultConfig, TraceConfig } from '../trace-core/types/config'
import { TopicContextManager } from './TopicContextManager'
export const contextManager = new TopicContextManager()
export class WebTracer {
private static provider: WebTracerProvider
private static processor: SpanProcessor
static init(config?: TraceConfig, spanProcessor?: SpanProcessor) {
if (config) {
defaultConfig.serviceName = config.serviceName || defaultConfig.serviceName
defaultConfig.endpoint = config.endpoint || defaultConfig.endpoint
defaultConfig.headers = config.headers || defaultConfig.headers
defaultConfig.defaultTracerName = config.defaultTracerName || defaultConfig.defaultTracerName
}
this.processor = spanProcessor || new BatchSpanProcessor(this.getExporter())
this.provider = new WebTracerProvider({
spanProcessors: [this.processor]
})
this.provider.register({
propagator: new W3CTraceContextPropagator(),
contextManager: contextManager
})
}
private static getExporter() {
if (defaultConfig.endpoint) {
return new OTLPTraceExporter({
url: `${defaultConfig.endpoint}/v1/traces`,
headers: defaultConfig.headers
})
}
return new ConsoleSpanExporter()
}
}
export const startContext = contextManager.startContextForTopic.bind(contextManager)
export const getContext = contextManager.getContextForTopic.bind(contextManager)
export const endContext = contextManager.endContextForTopic.bind(contextManager)
export const cleanContext = contextManager.cleanContextForTopic.bind(contextManager)

View File

@ -257,5 +257,20 @@ export enum IpcChannel {
Memory_SetConfig = 'memory:set-config', Memory_SetConfig = 'memory:set-config',
Memory_DeleteUser = 'memory:delete-user', Memory_DeleteUser = 'memory:delete-user',
Memory_DeleteAllMemoriesForUser = 'memory:delete-all-memories-for-user', Memory_DeleteAllMemoriesForUser = 'memory:delete-all-memories-for-user',
Memory_GetUsersList = 'memory:get-users-list' Memory_GetUsersList = 'memory:get-users-list',
// TRACE
TRACE_SAVE_DATA = 'trace:saveData',
TRACE_GET_DATA = 'trace:getData',
TRACE_SAVE_ENTITY = 'trace:saveEntity',
TRACE_GET_ENTITY = 'trace:getEntity',
TRACE_BIND_TOPIC = 'trace:bindTopic',
TRACE_CLEAN_TOPIC = 'trace:cleanTopic',
TRACE_TOKEN_USAGE = 'trace:tokenUsage',
TRACE_CLEAN_HISTORY = 'trace:cleanHistory',
TRACE_OPEN_WINDOW = 'trace:openWindow',
TRACE_SET_TITLE = 'trace:setTitle',
TRACE_ADD_END_MESSAGE = 'trace:addEndMessage',
TRACE_CLEAN_LOCAL_DATA = 'trace:cleanLocalData',
TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage'
} }

View File

@ -15,6 +15,7 @@ import { isDev, isLinux, isWin } from './constant'
import { registerIpc } from './ipc' import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager' import { configManager } from './services/ConfigManager'
import mcpService from './services/MCPService' import mcpService from './services/MCPService'
import { nodeTraceService } from './services/NodeTraceService'
import { import {
CHERRY_STUDIO_PROTOCOL, CHERRY_STUDIO_PROTOCOL,
handleProtocolUrl, handleProtocolUrl,
@ -109,6 +110,8 @@ if (!app.requestSingleInstanceLock()) {
const mainWindow = windowService.createMainWindow() const mainWindow = windowService.createMainWindow()
new TrayService() new TrayService()
nodeTraceService.init()
app.on('activate', function () { app.on('activate', function () {
const mainWindow = windowService.getMainWindow() const mainWindow = windowService.getMainWindow()
if (!mainWindow || mainWindow.isDestroyed()) { if (!mainWindow || mainWindow.isDestroyed()) {

View File

@ -6,6 +6,7 @@ import { loggerService } from '@logger'
import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { isLinux, isMac, isPortable, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom' import { handleZoomFactor } from '@main/utils/zoom'
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { UpgradeChannel } from '@shared/config/constant' import { UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types' import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
@ -24,6 +25,7 @@ import FileService from './services/FileSystemService'
import KnowledgeService from './services/KnowledgeService' import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService' import mcpService from './services/MCPService'
import MemoryService from './services/memory/MemoryService' import MemoryService from './services/memory/MemoryService'
import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceService'
import NotificationService from './services/NotificationService' import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService' import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService' import ObsidianVaultService from './services/ObsidianVaultService'
@ -33,6 +35,19 @@ import { FileServiceManager } from './services/remotefile/FileServiceManager'
import { searchService } from './services/SearchService' import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService' import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import {
addEndMessage,
addStreamMessage,
bindTopic,
cleanHistoryTrace,
cleanLocalData,
cleanTopic,
getEntity,
getSpans,
saveEntity,
saveSpans,
tokenUsage
} from './services/SpanCacheService'
import storeSyncService from './services/StoreSyncService' import storeSyncService from './services/StoreSyncService'
import { themeService } from './services/ThemeService' import { themeService } from './services/ThemeService'
import VertexAIService from './services/VertexAIService' import VertexAIService from './services/VertexAIService'
@ -371,49 +386,49 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}) })
// backup // backup
ipcMain.handle(IpcChannel.Backup_Backup, backupManager.backup) ipcMain.handle(IpcChannel.Backup_Backup, backupManager.backup.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_Restore, backupManager.restore) ipcMain.handle(IpcChannel.Backup_Restore, backupManager.restore.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_BackupToWebdav, backupManager.backupToWebdav) ipcMain.handle(IpcChannel.Backup_BackupToWebdav, backupManager.backupToWebdav.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_RestoreFromWebdav, backupManager.restoreFromWebdav) ipcMain.handle(IpcChannel.Backup_RestoreFromWebdav, backupManager.restoreFromWebdav.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_ListWebdavFiles, backupManager.listWebdavFiles) ipcMain.handle(IpcChannel.Backup_ListWebdavFiles, backupManager.listWebdavFiles.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection) ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory) ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile) ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_BackupToLocalDir, backupManager.backupToLocalDir) ipcMain.handle(IpcChannel.Backup_BackupToLocalDir, backupManager.backupToLocalDir.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_RestoreFromLocalBackup, backupManager.restoreFromLocalBackup) ipcMain.handle(IpcChannel.Backup_RestoreFromLocalBackup, backupManager.restoreFromLocalBackup.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_ListLocalBackupFiles, backupManager.listLocalBackupFiles) ipcMain.handle(IpcChannel.Backup_ListLocalBackupFiles, backupManager.listLocalBackupFiles.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_DeleteLocalBackupFile, backupManager.deleteLocalBackupFile) ipcMain.handle(IpcChannel.Backup_DeleteLocalBackupFile, backupManager.deleteLocalBackupFile.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_SetLocalBackupDir, backupManager.setLocalBackupDir) ipcMain.handle(IpcChannel.Backup_SetLocalBackupDir, backupManager.setLocalBackupDir.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3) ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3) ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files) ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File) ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File.bind(backupManager))
ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection) ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection.bind(backupManager))
// file // file
ipcMain.handle(IpcChannel.File_Open, fileManager.open) ipcMain.handle(IpcChannel.File_Open, fileManager.open.bind(fileManager))
ipcMain.handle(IpcChannel.File_OpenPath, fileManager.openPath) ipcMain.handle(IpcChannel.File_OpenPath, fileManager.openPath.bind(fileManager))
ipcMain.handle(IpcChannel.File_Save, fileManager.save) ipcMain.handle(IpcChannel.File_Save, fileManager.save.bind(fileManager))
ipcMain.handle(IpcChannel.File_Select, fileManager.selectFile) ipcMain.handle(IpcChannel.File_Select, fileManager.selectFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Upload, fileManager.uploadFile) ipcMain.handle(IpcChannel.File_Upload, fileManager.uploadFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear) ipcMain.handle(IpcChannel.File_Clear, fileManager.clear.bind(fileManager))
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile) ipcMain.handle(IpcChannel.File_Read, fileManager.readFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile) ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile.bind(fileManager))
ipcMain.handle('file:deleteDir', fileManager.deleteDir) ipcMain.handle('file:deleteDir', fileManager.deleteDir.bind(fileManager))
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile) ipcMain.handle(IpcChannel.File_Get, fileManager.getFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder) ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder.bind(fileManager))
ipcMain.handle(IpcChannel.File_CreateTempFile, fileManager.createTempFile) ipcMain.handle(IpcChannel.File_CreateTempFile, fileManager.createTempFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile) ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId) ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId.bind(fileManager))
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage) ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image) ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image.bind(fileManager))
ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image) ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image.bind(fileManager))
ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File) ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File.bind(fileManager))
ipcMain.handle(IpcChannel.File_GetPdfInfo, fileManager.pdfPageCount) ipcMain.handle(IpcChannel.File_GetPdfInfo, fileManager.pdfPageCount.bind(fileManager))
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile) ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile) ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage) ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath) ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
// file service // file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => { ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
@ -437,10 +452,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}) })
// fs // fs
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile) ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile.bind(FileService))
// export // export
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord) ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService))
// open path // open path
ipcMain.handle(IpcChannel.Open_Path, async (_, path: string) => { ipcMain.handle(IpcChannel.Open_Path, async (_, path: string) => {
@ -458,14 +473,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}) })
// knowledge base // knowledge base
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create) ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Reset, KnowledgeService.reset) ipcMain.handle(IpcChannel.KnowledgeBase_Reset, KnowledgeService.reset.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Delete, KnowledgeService.delete) ipcMain.handle(IpcChannel.KnowledgeBase_Delete, KnowledgeService.delete.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Add, KnowledgeService.add) ipcMain.handle(IpcChannel.KnowledgeBase_Add, KnowledgeService.add.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Remove, KnowledgeService.remove) ipcMain.handle(IpcChannel.KnowledgeBase_Remove, KnowledgeService.remove.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Search, KnowledgeService.search) ipcMain.handle(IpcChannel.KnowledgeBase_Search, KnowledgeService.search.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank) ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Check_Quota, KnowledgeService.checkQuota) ipcMain.handle(IpcChannel.KnowledgeBase_Check_Quota, KnowledgeService.checkQuota.bind(KnowledgeService))
// memory // memory
ipcMain.handle(IpcChannel.Memory_Add, async (_, messages, config) => { ipcMain.handle(IpcChannel.Memory_Add, async (_, messages, config) => {
@ -586,12 +601,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js')) ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
//copilot //copilot
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage) ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage.bind(CopilotService))
ipcMain.handle(IpcChannel.Copilot_GetCopilotToken, CopilotService.getCopilotToken) ipcMain.handle(IpcChannel.Copilot_GetCopilotToken, CopilotService.getCopilotToken.bind(CopilotService))
ipcMain.handle(IpcChannel.Copilot_SaveCopilotToken, CopilotService.saveCopilotToken) ipcMain.handle(IpcChannel.Copilot_SaveCopilotToken, CopilotService.saveCopilotToken.bind(CopilotService))
ipcMain.handle(IpcChannel.Copilot_GetToken, CopilotService.getToken) ipcMain.handle(IpcChannel.Copilot_GetToken, CopilotService.getToken.bind(CopilotService))
ipcMain.handle(IpcChannel.Copilot_Logout, CopilotService.logout) ipcMain.handle(IpcChannel.Copilot_Logout, CopilotService.logout.bind(CopilotService))
ipcMain.handle(IpcChannel.Copilot_GetUser, CopilotService.getUser) ipcMain.handle(IpcChannel.Copilot_GetUser, CopilotService.getUser.bind(CopilotService))
// Obsidian service // Obsidian service
ipcMain.handle(IpcChannel.Obsidian_GetVaults, () => { ipcMain.handle(IpcChannel.Obsidian_GetVaults, () => {
@ -603,7 +618,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}) })
// nutstore // nutstore
ipcMain.handle(IpcChannel.Nutstore_GetSsoUrl, NutstoreService.getNutstoreSSOUrl) ipcMain.handle(IpcChannel.Nutstore_GetSsoUrl, NutstoreService.getNutstoreSSOUrl.bind(NutstoreService))
ipcMain.handle(IpcChannel.Nutstore_DecryptToken, (_, token: string) => NutstoreService.decryptToken(token)) ipcMain.handle(IpcChannel.Nutstore_DecryptToken, (_, token: string) => NutstoreService.decryptToken(token))
ipcMain.handle(IpcChannel.Nutstore_GetDirectoryContents, (_, token: string, path: string) => ipcMain.handle(IpcChannel.Nutstore_GetDirectoryContents, (_, token: string, path: string) =>
NutstoreService.getDirectoryContents(token, path) NutstoreService.getDirectoryContents(token, path)
@ -642,4 +657,31 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.App_SetDisableHardwareAcceleration, (_, isDisable: boolean) => { ipcMain.handle(IpcChannel.App_SetDisableHardwareAcceleration, (_, isDisable: boolean) => {
configManager.setDisableHardwareAcceleration(isDisable) configManager.setDisableHardwareAcceleration(isDisable)
}) })
ipcMain.handle(IpcChannel.TRACE_SAVE_DATA, (_, topicId: string) => saveSpans(topicId))
ipcMain.handle(IpcChannel.TRACE_GET_DATA, (_, topicId: string, traceId: string, modelName?: string) =>
getSpans(topicId, traceId, modelName)
)
ipcMain.handle(IpcChannel.TRACE_SAVE_ENTITY, (_, entity: SpanEntity) => saveEntity(entity))
ipcMain.handle(IpcChannel.TRACE_GET_ENTITY, (_, spanId: string) => getEntity(spanId))
ipcMain.handle(IpcChannel.TRACE_BIND_TOPIC, (_, topicId: string, traceId: string) => bindTopic(traceId, topicId))
ipcMain.handle(IpcChannel.TRACE_CLEAN_TOPIC, (_, topicId: string, traceId?: string) => cleanTopic(topicId, traceId))
ipcMain.handle(IpcChannel.TRACE_TOKEN_USAGE, (_, spanId: string, usage: TokenUsage) => tokenUsage(spanId, usage))
ipcMain.handle(IpcChannel.TRACE_CLEAN_HISTORY, (_, topicId: string, traceId: string, modelName?: string) =>
cleanHistoryTrace(topicId, traceId, modelName)
)
ipcMain.handle(
IpcChannel.TRACE_OPEN_WINDOW,
(_, topicId: string, traceId: string, autoOpen?: boolean, modelName?: string) =>
openTraceWindow(topicId, traceId, autoOpen, modelName)
)
ipcMain.handle(IpcChannel.TRACE_SET_TITLE, (_, title: string) => setTraceWindowTitle(title))
ipcMain.handle(IpcChannel.TRACE_ADD_END_MESSAGE, (_, spanId: string, modelName: string, message: string) =>
addEndMessage(spanId, modelName, message)
)
ipcMain.handle(IpcChannel.TRACE_CLEAN_LOCAL_DATA, () => cleanLocalData())
ipcMain.handle(
IpcChannel.TRACE_ADD_STREAM_MESSAGE,
(_, spanId: string, modelName: string, context: string, msg: any) =>
addStreamMessage(spanId, modelName, context, msg)
)
} }

View File

@ -1,4 +1,5 @@
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces' import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { TraceMethod } from '@mcp-trace/trace-core'
import { ApiClient } from '@types' import { ApiClient } from '@types'
import EmbeddingsFactory from './EmbeddingsFactory' import EmbeddingsFactory from './EmbeddingsFactory'
@ -14,13 +15,18 @@ export default class Embeddings {
public async init(): Promise<void> { public async init(): Promise<void> {
return this.sdk.init() return this.sdk.init()
} }
@TraceMethod({ spanName: 'dimensions', tag: 'Embeddings' })
public async getDimensions(): Promise<number> { public async getDimensions(): Promise<number> {
return this.sdk.getDimensions() return this.sdk.getDimensions()
} }
@TraceMethod({ spanName: 'embedDocuments', tag: 'Embeddings' })
public async embedDocuments(texts: string[]): Promise<number[][]> { public async embedDocuments(texts: string[]): Promise<number[][]> {
return this.sdk.embedDocuments(texts) return this.sdk.embedDocuments(texts)
} }
@TraceMethod({ spanName: 'embedQuery', tag: 'Embeddings' })
public async embedQuery(text: string): Promise<number[]> { public async embedQuery(text: string): Promise<number[]> {
return this.sdk.embedQuery(text) return this.sdk.embedQuery(text)
} }

View File

@ -1,5 +1,6 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { getConfigDir } from '@main/utils/file' import { getConfigDir } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js' import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
import { Mutex } from 'async-mutex' // 引入 Mutex import { Mutex } from 'async-mutex' // 引入 Mutex
@ -45,6 +46,7 @@ class KnowledgeGraphManager {
} }
// Static async factory method for initialization // Static async factory method for initialization
@TraceMethod({ spanName: 'create', tag: 'KnowledgeGraph' })
public static async create(memoryPath: string): Promise<KnowledgeGraphManager> { public static async create(memoryPath: string): Promise<KnowledgeGraphManager> {
const manager = new KnowledgeGraphManager(memoryPath) const manager = new KnowledgeGraphManager(memoryPath)
await manager._ensureMemoryPathExists() await manager._ensureMemoryPathExists()
@ -143,6 +145,7 @@ class KnowledgeGraphManager {
return JSON.parse(relationStr) as Relation return JSON.parse(relationStr) as Relation
} }
@TraceMethod({ spanName: 'createEntities', tag: 'KnowledgeGraph' })
async createEntities(entities: Entity[]): Promise<Entity[]> { async createEntities(entities: Entity[]): Promise<Entity[]> {
const newEntities: Entity[] = [] const newEntities: Entity[] = []
entities.forEach((entity) => { entities.forEach((entity) => {
@ -159,6 +162,7 @@ class KnowledgeGraphManager {
return newEntities return newEntities
} }
@TraceMethod({ spanName: 'createRelations', tag: 'KnowledgeGraph' })
async createRelations(relations: Relation[]): Promise<Relation[]> { async createRelations(relations: Relation[]): Promise<Relation[]> {
const newRelations: Relation[] = [] const newRelations: Relation[] = []
relations.forEach((relation) => { relations.forEach((relation) => {
@ -179,6 +183,7 @@ class KnowledgeGraphManager {
return newRelations return newRelations
} }
@TraceMethod({ spanName: 'addObservtions', tag: 'KnowledgeGraph' })
async addObservations( async addObservations(
observations: { entityName: string; contents: string[] }[] observations: { entityName: string; contents: string[] }[]
): Promise<{ entityName: string; addedObservations: string[] }[]> { ): Promise<{ entityName: string; addedObservations: string[] }[]> {
@ -213,6 +218,7 @@ class KnowledgeGraphManager {
return results return results
} }
@TraceMethod({ spanName: 'deleteEntities', tag: 'KnowledgeGraph' })
async deleteEntities(entityNames: string[]): Promise<void> { async deleteEntities(entityNames: string[]): Promise<void> {
let changed = false let changed = false
const namesToDelete = new Set(entityNames) const namesToDelete = new Set(entityNames)
@ -244,6 +250,7 @@ class KnowledgeGraphManager {
} }
} }
@TraceMethod({ spanName: 'deleteObservations', tag: 'KnowledgeGraph' })
async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<void> { async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<void> {
let changed = false let changed = false
deletions.forEach((d) => { deletions.forEach((d) => {
@ -262,6 +269,7 @@ class KnowledgeGraphManager {
} }
} }
@TraceMethod({ spanName: 'deleteRelations', tag: 'KnowledgeGraph' })
async deleteRelations(relations: Relation[]): Promise<void> { async deleteRelations(relations: Relation[]): Promise<void> {
let changed = false let changed = false
relations.forEach((rel) => { relations.forEach((rel) => {
@ -276,6 +284,7 @@ class KnowledgeGraphManager {
} }
// Read the current state from memory // Read the current state from memory
@TraceMethod({ spanName: 'readGraph', tag: 'KnowledgeGraph' })
async readGraph(): Promise<KnowledgeGraph> { async readGraph(): Promise<KnowledgeGraph> {
// Return a deep copy to prevent external modification of the internal state // Return a deep copy to prevent external modification of the internal state
return JSON.parse( return JSON.parse(
@ -287,6 +296,7 @@ class KnowledgeGraphManager {
} }
// Search operates on the in-memory graph // Search operates on the in-memory graph
@TraceMethod({ spanName: 'searchNodes', tag: 'KnowledgeGraph' })
async searchNodes(query: string): Promise<KnowledgeGraph> { async searchNodes(query: string): Promise<KnowledgeGraph> {
const lowerCaseQuery = query.toLowerCase() const lowerCaseQuery = query.toLowerCase()
const filteredEntities = Array.from(this.entities.values()).filter( const filteredEntities = Array.from(this.entities.values()).filter(
@ -309,6 +319,7 @@ class KnowledgeGraphManager {
} }
// Open operates on the in-memory graph // Open operates on the in-memory graph
@TraceMethod({ spanName: 'openNodes', tag: 'KnowledgeGraph' })
async openNodes(names: string[]): Promise<KnowledgeGraph> { async openNodes(names: string[]): Promise<KnowledgeGraph> {
const nameSet = new Set(names) const nameSet = new Set(names)
const filteredEntities = Array.from(this.entities.values()).filter((e) => nameSet.has(e.name)) const filteredEntities = Array.from(this.entities.values()).filter((e) => nameSet.has(e.name))

View File

@ -45,6 +45,7 @@ class FileStorage {
} }
} }
// @TraceProperty({ spanName: 'getFileHash', tag: 'FileStorage' })
private getFileHash = async (filePath: string): Promise<string> => { private getFileHash = async (filePath: string): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5') const hash = crypto.createHash('md5')
@ -219,6 +220,7 @@ class FileStorage {
return fileInfo return fileInfo
} }
// @TraceProperty({ spanName: 'deleteFile', tag: 'FileStorage' })
public deleteFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => { public deleteFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
if (!fs.existsSync(path.join(this.storageDir, id))) { if (!fs.existsSync(path.join(this.storageDir, id))) {
return return
@ -586,6 +588,7 @@ class FileStorage {
return mimeToExtension[mimeType] || '.bin' return mimeToExtension[mimeType] || '.bin'
} }
// @TraceProperty({ spanName: 'copyFile', tag: 'FileStorage' })
public copyFile = async (_: Electron.IpcMainInvokeEvent, id: string, destPath: string): Promise<void> => { public copyFile = async (_: Electron.IpcMainInvokeEvent, id: string, destPath: string): Promise<void> => {
try { try {
const sourcePath = path.join(this.storageDir, id) const sourcePath = path.join(this.storageDir, id)

View File

@ -1,6 +1,8 @@
import { TraceMethod } from '@mcp-trace/trace-core'
import fs from 'fs/promises' import fs from 'fs/promises'
export default class FileService { export default class FileService {
@TraceMethod({ spanName: 'readFile', tag: 'FileService' })
public static async readFile(_: Electron.IpcMainInvokeEvent, pathOrUrl: string, encoding?: BufferEncoding) { public static async readFile(_: Electron.IpcMainInvokeEvent, pathOrUrl: string, encoding?: BufferEncoding) {
const path = pathOrUrl.startsWith('file://') ? new URL(pathOrUrl) : pathOrUrl const path = pathOrUrl.startsWith('file://') ? new URL(pathOrUrl) : pathOrUrl
if (encoding) return fs.readFile(path, { encoding }) if (encoding) return fs.readFile(path, { encoding })

View File

@ -31,6 +31,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
import { windowService } from '@main/services/WindowService' import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils' import { getDataPath } from '@main/utils'
import { getAllFiles } from '@main/utils/file' import { getAllFiles } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant' import { MB } from '@shared/config/constant'
import type { LoaderReturn } from '@shared/config/types' import type { LoaderReturn } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
@ -155,7 +156,7 @@ class KnowledgeService {
await ragApplication.reset() await ragApplication.reset()
} }
public delete = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => { public async delete(_: Electron.IpcMainInvokeEvent, id: string): Promise<void> {
logger.debug('delete id', id) logger.debug('delete id', id)
const dbPath = path.join(this.storageDir, id) const dbPath = path.join(this.storageDir, id)
if (fs.existsSync(dbPath)) { if (fs.existsSync(dbPath)) {
@ -473,7 +474,7 @@ class KnowledgeService {
}) })
} }
public add = async (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => { public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
return new Promise((resolve) => { return new Promise((resolve) => {
const { base, item, forceReload = false, userId = '' } = options const { base, item, forceReload = false, userId = '' } = options
const optionsNonNullableAttribute = { base, item, forceReload, userId } const optionsNonNullableAttribute = { base, item, forceReload, userId }
@ -520,10 +521,11 @@ class KnowledgeService {
}) })
} }
public remove = async ( @TraceMethod({ spanName: 'remove', tag: 'Knowledge' })
public async remove(
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
{ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams } { uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }
): Promise<void> => { ): Promise<void> {
const ragApplication = await this.getRagApplication(base) const ragApplication = await this.getRagApplication(base)
logger.debug(`Remove Item UniqueId: ${uniqueId}`) logger.debug(`Remove Item UniqueId: ${uniqueId}`)
for (const id of uniqueIds) { for (const id of uniqueIds) {
@ -531,18 +533,20 @@ class KnowledgeService {
} }
} }
public search = async ( @TraceMethod({ spanName: 'RagSearch', tag: 'Knowledge' })
public async search(
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
{ search, base }: { search: string; base: KnowledgeBaseParams } { search, base }: { search: string; base: KnowledgeBaseParams }
): Promise<ExtractChunkData[]> => { ): Promise<ExtractChunkData[]> {
const ragApplication = await this.getRagApplication(base) const ragApplication = await this.getRagApplication(base)
return await ragApplication.search(search) return await ragApplication.search(search)
} }
public rerank = async ( @TraceMethod({ spanName: 'rerank', tag: 'Knowledge' })
public async rerank(
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] } { search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }
): Promise<ExtractChunkData[]> => { ): Promise<ExtractChunkData[]> {
if (results.length === 0) { if (results.length === 0) {
return results return results
} }

View File

@ -7,6 +7,7 @@ import { createInMemoryMCPServer } from '@main/mcpServers/factory'
import { makeSureDirExists } from '@main/utils' import { makeSureDirExists } from '@main/utils'
import { buildFunctionCallToolName } from '@main/utils/mcp' import { buildFunctionCallToolName } from '@main/utils/mcp'
import { getBinaryName, getBinaryPath } from '@main/utils/process' import { getBinaryName, getBinaryPath } from '@main/utils/process'
import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js' import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
@ -26,7 +27,7 @@ import {
ToolListChangedNotificationSchema ToolListChangedNotificationSchema
} from '@modelcontextprotocol/sdk/types.js' } from '@modelcontextprotocol/sdk/types.js'
import { nanoid } from '@reduxjs/toolkit' import { nanoid } from '@reduxjs/toolkit'
import { import type {
GetMCPPromptResponse, GetMCPPromptResponse,
GetResourceResponse, GetResourceResponse,
MCPCallToolResponse, MCPCallToolResponse,
@ -49,6 +50,8 @@ import getLoginShellEnvironment from './mcp/shell-env'
// Generic type for caching wrapped functions // Generic type for caching wrapped functions
type CachedFunction<T extends unknown[], R> = (...args: T) => Promise<R> type CachedFunction<T extends unknown[], R> = (...args: T) => Promise<R>
type CallToolArgs = { server: MCPServer; name: string; args: any; callId?: string }
const logger = loggerService.withContext('MCPService') const logger = loggerService.withContext('MCPService')
/** /**
@ -580,17 +583,22 @@ class McpService {
} }
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) { async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const cachedListTools = withCache<[MCPServer], MCPTool[]>( const listFunc = (server: MCPServer) => {
this.listToolsImpl.bind(this), const cachedListTools = withCache<[MCPServer], MCPTool[]>(
(server) => { this.listToolsImpl.bind(this),
const serverKey = this.getServerKey(server) (server) => {
return `mcp:list_tool:${serverKey}` const serverKey = this.getServerKey(server)
}, return `mcp:list_tool:${serverKey}`
5 * 60 * 1000, // 5 minutes TTL },
`[MCP] Tools from ${server.name}` 5 * 60 * 1000, // 5 minutes TTL
) `[MCP] Tools from ${server.name}`
)
return cachedListTools(server) const result = cachedListTools(server)
return result
}
return withSpanFunc(`${server.name}.ListTool`, 'MCP', listFunc, [server])
} }
/** /**
@ -598,37 +606,41 @@ class McpService {
*/ */
public async callTool( public async callTool(
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
{ server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string } { server, name, args, callId }: CallToolArgs
): Promise<MCPCallToolResponse> { ): Promise<MCPCallToolResponse> {
const toolCallId = callId || uuidv4() const toolCallId = callId || uuidv4()
const abortController = new AbortController() const abortController = new AbortController()
this.activeToolCalls.set(toolCallId, abortController) this.activeToolCalls.set(toolCallId, abortController)
try { const callToolFunc = async ({ server, name, args }: CallToolArgs) => {
logger.debug('Calling:', server.name, name, args, 'callId:', toolCallId) try {
if (typeof args === 'string') { logger.debug('Calling:', server.name, name, args, 'callId:', toolCallId)
try { if (typeof args === 'string') {
args = JSON.parse(args) try {
} catch (e) { args = JSON.parse(args)
logger.error('args parse error', args) } catch (e) {
logger.error('args parse error', args)
}
} }
const client = await this.initClient(server)
const result = await client.callTool({ name, arguments: args }, undefined, {
onprogress: (process) => {
logger.debug(`Progress: ${process.progress / (process.total || 1)}`)
window.api.mcp.setProgress(process.progress / (process.total || 1))
},
timeout: server.timeout ? server.timeout * 1000 : 60000, // Default timeout of 1 minute
signal: this.activeToolCalls.get(toolCallId)?.signal
})
return result as MCPCallToolResponse
} catch (error) {
logger.error(`Error calling tool ${name} on ${server.name}:`, error)
throw error
} finally {
this.activeToolCalls.delete(toolCallId)
} }
const client = await this.initClient(server)
const result = await client.callTool({ name, arguments: args }, undefined, {
onprogress: (process) => {
logger.debug(`Progress: ${process.progress / (process.total || 1)}`)
window.api.mcp.setProgress(process.progress / (process.total || 1))
},
timeout: server.timeout ? server.timeout * 1000 : 60000, // Default timeout of 1 minute
signal: this.activeToolCalls.get(toolCallId)?.signal
})
return result as MCPCallToolResponse
} catch (error) {
logger.error(`Error calling tool ${name} on ${server.name}:`, error)
throw error
} finally {
this.activeToolCalls.delete(toolCallId)
} }
return await withSpanFunc(`${server.name}.${name}`, `MCP`, callToolFunc, [{ server, name, args }])
} }
public async getInstallInfo() { public async getInstallInfo() {
@ -695,6 +707,7 @@ class McpService {
/** /**
* Get a specific prompt from an MCP server with caching * Get a specific prompt from an MCP server with caching
*/ */
@TraceMethod({ spanName: 'getPrompt', tag: 'mcp' })
public async getPrompt( public async getPrompt(
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
{ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> } { server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }
@ -781,6 +794,7 @@ class McpService {
/** /**
* Get a specific resource from an MCP server with caching * Get a specific resource from an MCP server with caching
*/ */
@TraceMethod({ spanName: 'getResource', tag: 'mcp' })
public async getResource( public async getResource(
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
{ server, uri }: { server: MCPServer; uri: string } { server, uri }: { server: MCPServer; uri: string }

View File

@ -0,0 +1,122 @@
import { isDev } from '@main/constant'
import { CacheBatchSpanProcessor, defaultConfig, FunctionSpanExporter } from '@mcp-trace/trace-core'
import { NodeTracer as MCPNodeTracer } from '@mcp-trace/trace-node/nodeTracer'
import { context, SpanContext, trace } from '@opentelemetry/api'
import { BrowserWindow, ipcMain } from 'electron'
import * as path from 'path'
import { ConfigKeys, configManager } from './ConfigManager'
import { spanCacheService } from './SpanCacheService'
export const TRACER_NAME = 'CherryStudio'
export class NodeTraceService {
init() {
// TODO get developer mode setting from config
defaultConfig.isDevModel = true
const exporter = new FunctionSpanExporter(async (spans) => {
console.log(`Spans length:`, spans.length)
})
MCPNodeTracer.init(
{
defaultTracerName: TRACER_NAME,
serviceName: TRACER_NAME
},
new CacheBatchSpanProcessor(exporter, spanCacheService)
)
}
}
const originalHandle = ipcMain.handle
ipcMain.handle = (channel: string, handler: (...args: any[]) => Promise<any>) => {
return originalHandle.call(ipcMain, channel, async (event, ...args) => {
const carray = args && args.length > 0 ? args[args.length - 1] : {}
let ctx = context.active()
let newArgs = args
if (carray && typeof carray === 'object' && 'type' in carray && carray.type === 'trace') {
const span = trace.wrapSpanContext(carray.context as SpanContext)
ctx = trace.setSpan(context.active(), span)
newArgs = args.slice(0, args.length - 1)
}
return context.with(ctx, () => handler(event, ...newArgs))
})
}
export const nodeTraceService = new NodeTraceService()
let traceWin: BrowserWindow | null = null
export function openTraceWindow(topicId: string, traceId: string, autoOpen = true, modelName?: string) {
if (traceWin && !traceWin.isDestroyed()) {
traceWin.focus()
traceWin.webContents.send('set-trace', { traceId, topicId, modelName })
return
}
if (!traceWin && !autoOpen) {
return
}
traceWin = new BrowserWindow({
width: 600,
minWidth: 500,
minHeight: 600,
height: 800,
autoHideMenuBar: true,
closable: true,
focusable: true,
movable: true,
hasShadow: true,
roundedCorners: true,
maximizable: true,
minimizable: true,
resizable: true,
title: 'Call Chain Window',
frame: false,
titleBarStyle: 'hidden',
titleBarOverlay: { height: 40 },
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
devTools: isDev ? true : false
}
})
if (isDev && process.env['ELECTRON_RENDERER_URL']) {
traceWin.loadURL(process.env['ELECTRON_RENDERER_URL'] + `/traceWindow.html`)
} else {
traceWin.loadFile(path.join(__dirname, '../renderer/traceWindow.html'))
}
traceWin.on('closed', () => {
configManager.unsubscribe(ConfigKeys.Language, setLanguageCallback)
try {
traceWin?.destroy()
} finally {
traceWin = null
}
})
traceWin.webContents.on('did-finish-load', () => {
traceWin!.webContents.send('set-trace', {
traceId,
topicId,
modelName
})
traceWin!.webContents.send('set-language', { lang: configManager.get(ConfigKeys.Language) })
configManager.subscribe(ConfigKeys.Language, setLanguageCallback)
})
}
const setLanguageCallback = (lang: string) => {
traceWin!.webContents.send('set-language', { lang })
}
export const setTraceWindowTitle = (title: string) => {
if (traceWin) {
traceWin.title = title
}
}

View File

@ -0,0 +1,409 @@
import {
Attributes,
convertSpanToSpanEntity,
defaultConfig,
SpanEntity,
TokenUsage,
TraceCache
} from '@mcp-trace/trace-core'
import { SpanStatusCode } from '@opentelemetry/api'
import { ReadableSpan } from '@opentelemetry/sdk-trace-base'
import fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
class SpanCacheService implements TraceCache {
private topicMap: Map<string, string> = new Map<string, string>()
private fileDir: string
private cache: Map<string, SpanEntity> = new Map<string, SpanEntity>()
pri
constructor() {
this.fileDir = path.join(os.homedir(), '.cherrystudio', 'trace')
}
createSpan: (span: ReadableSpan) => void = (span: ReadableSpan) => {
if (!defaultConfig.isDevModel) {
return
}
const spanEntity = convertSpanToSpanEntity(span)
spanEntity.topicId = this.topicMap.get(spanEntity.traceId)
this.cache.set(span.spanContext().spanId, spanEntity)
this._updateModelName(spanEntity)
}
endSpan: (span: ReadableSpan) => void = (span: ReadableSpan) => {
if (!defaultConfig.isDevModel) {
return
}
const spanId = span.spanContext().spanId
const spanEntity = this.cache.get(spanId)
if (!spanEntity) {
return
}
spanEntity.topicId = this.topicMap.get(spanEntity.traceId)
spanEntity.endTime = span.endTime ? span.endTime[0] * 1e3 + Math.floor(span.endTime[1] / 1e6) : null
spanEntity.status = SpanStatusCode[span.status.code]
spanEntity.attributes = span.attributes ? ({ ...span.attributes } as Attributes) : {}
spanEntity.events = span.events
spanEntity.links = span.links
this._updateModelName(spanEntity)
}
clear: () => void = () => {
this.cache.clear()
}
async cleanTopic(topicId: string, traceId?: string, modelName?: string) {
const spans = Array.from(this.cache.values().filter((e) => e.topicId === topicId))
spans.map((e) => e.id).forEach((id) => this.cache.delete(id))
await this._checkFolder(path.join(this.fileDir, topicId))
if (modelName) {
this.cleanHistoryTrace(topicId, traceId || '', modelName)
this.saveSpans(topicId)
} else if (traceId) {
fs.rm(path.join(this.fileDir, topicId, traceId))
} else {
fs.readdir(path.join(this.fileDir, topicId)).then((files) =>
files.forEach((file) => {
fs.rm(path.join(this.fileDir, topicId, file))
})
)
}
}
async cleanLocalData() {
this.cache.clear()
fs.readdir(this.fileDir)
.then((files) =>
files.forEach((topicId) => {
fs.rm(path.join(this.fileDir, topicId), { recursive: true, force: true })
})
)
.catch((err) => {
console.error('Error cleaning local data:', err)
})
}
async saveSpans(topicId: string) {
if (!defaultConfig.isDevModel) {
return
}
let traceId: string | undefined
for (const [key, value] of this.topicMap.entries()) {
if (value === topicId) {
traceId = key
break // 找到后立即退出循环
}
}
if (!traceId) {
return
}
const spans = Array.from(this.cache.values().filter((e) => e.traceId === traceId || !e.modelName))
await this._saveToFile(spans, traceId, topicId)
this.topicMap.delete(traceId)
this._cleanCache(traceId)
}
async getSpans(topicId: string, traceId: string, modelName?: string) {
if (this.topicMap.has(traceId)) {
const spans: SpanEntity[] = []
this.cache
.values()
.filter((spanEntity) => {
return spanEntity.traceId === traceId && spanEntity.modelName
})
.filter((spanEntity) => {
return !modelName || spanEntity.modelName === modelName
})
.forEach((sp) => spans.push(sp))
return spans
} else {
return this._getHisData(topicId, traceId, modelName)
}
}
/**
* binding topic id to trace
* @param traceId traceId
* @param topicId topicId
*/
setTopicId(traceId: string, topicId: string): void {
this.topicMap.set(traceId, topicId)
}
getEntity(spanId: string): SpanEntity | undefined {
return this.cache.get(spanId)
}
saveEntity(entity: SpanEntity) {
if (!defaultConfig.isDevModel) {
return
}
if (this.cache.has(entity.id)) {
this._updateEntity(entity)
} else {
this._addEntity(entity)
}
this._updateModelName(entity)
}
updateTokenUsage(spanId: string, usage: TokenUsage) {
const entity = this.cache.get(spanId)
if (entity) {
entity.usage = { ...usage }
}
if (entity?.parentId) {
this._updateParentUsage(entity.parentId, usage)
}
}
addStreamMessage(spanId: string, modelName: string, context: string, message: any) {
const span = this.cache.get(spanId)
if (!span) {
return
}
const attributes = span.attributes
let msgArray: any[] = []
if (attributes && attributes['outputs'] && Array.isArray(attributes['outputs'])) {
msgArray = attributes['outputs'] || []
msgArray.push(message)
attributes['outputs'] = msgArray
} else {
msgArray = [message]
span.attributes = { ...attributes, outputs: msgArray } as Attributes
}
this._updateParentOutputs(span.parentId, modelName, context)
}
setEndMessage(spanId: string, modelName: string, message: string) {
const span = this.cache.get(spanId)
if (span && span.attributes) {
let outputs = span.attributes['outputs']
if (!outputs || typeof outputs !== 'object') {
outputs = {}
}
if (!(`${modelName}` in outputs) || !outputs[`${modelName}`]) {
outputs[`${modelName}`] = message
span.attributes[`outputs`] = outputs
this.cache.set(spanId, span)
}
}
}
async cleanHistoryTrace(topicId: string, traceId: string, modelName?: string) {
this._cleanCache(traceId, modelName)
const filePath = path.join(this.fileDir, topicId, traceId)
const fileExists = await this._existFile(filePath)
if (!fileExists) {
return
}
if (!modelName) {
await fs.rm(filePath, { recursive: true })
} else {
const allSpans = await this._getHisData(topicId, traceId)
allSpans.forEach((span) => {
if (!modelName || modelName !== span.modelName) {
this.cache.set(span.id, span)
}
})
try {
await fs.rm(filePath, { recursive: true })
} catch (error) {
console.error(error)
}
}
}
private _addEntity(entity: SpanEntity): void {
entity.topicId = this.topicMap.get(entity.traceId)
this.cache.set(entity.id, entity)
}
private _updateModelName(entity: SpanEntity) {
let modelName = entity.modelName || entity.attributes?.modelName?.toString()
if (!modelName && entity.parentId) {
modelName = this.cache.get(entity.parentId)?.modelName
}
entity.modelName = modelName
}
private _updateEntity(entity: SpanEntity): void {
entity.topicId = this.topicMap.get(entity.traceId)
const savedEntity = this.cache.get(entity.id)
if (savedEntity) {
Object.keys(entity).forEach((key) => {
const value = entity[key]
if (value === undefined) {
savedEntity[key] = value
return
}
if (key === 'attributes') {
const savedAttrs = savedEntity.attributes || {}
Object.keys(value).forEach((attrKey) => {
const jsonData =
typeof value[attrKey] === 'string' && value[attrKey].startsWith('{')
? JSON.parse(value[attrKey])
: value[attrKey]
if (
savedAttrs[attrKey] !== undefined &&
typeof jsonData === 'object' &&
typeof savedAttrs[attrKey] === 'object'
) {
savedAttrs[attrKey] = { ...savedAttrs[attrKey], ...jsonData }
} else {
savedAttrs[attrKey] = value[attrKey]
}
})
savedEntity.attributes = savedAttrs
} else {
savedEntity[key] = value
}
})
this.cache.set(entity.id, savedEntity)
}
}
private _cleanCache(traceId: string, modelName?: string) {
this.cache
.values()
.filter((span) => {
return span && span.traceId === traceId && (!modelName || span.modelName === modelName)
})
.forEach((span) => this.cache.delete(span.id))
}
private _updateParentOutputs(spanId: string, modelName: string, context: string) {
const span = this.cache.get(spanId)
if (!span || !context) {
return
}
const attributes = span.attributes
// 如果含有modelName属性是具体的某个modalName输出拼接到streamText下面
if (attributes && span.modelName) {
const currentValue = attributes['outputs']
if (currentValue && typeof currentValue === 'object') {
const allContext = (currentValue['streamText'] || '') + context
attributes['outputs'] = { ...currentValue, streamText: allContext }
} else {
attributes['outputs'] = { streamText: context }
}
span.attributes = attributes
} else if (span.modelName) {
span.attributes = { outputs: { [`${modelName}`]: context } } as Attributes
} else {
return
}
this.cache.set(span.id, span)
this._updateParentOutputs(span.parentId, modelName, context)
}
private _updateParentUsage(spanId: string, usage: TokenUsage) {
const entity = this.cache.get(spanId)
if (!entity) {
return
}
if (!entity.usage) {
entity.usage = { ...usage }
} else {
entity.usage.prompt_tokens = entity.usage.prompt_tokens + usage.prompt_tokens
entity.usage.completion_tokens = entity.usage.completion_tokens + usage.completion_tokens
entity.usage.total_tokens = entity.usage.total_tokens + usage.total_tokens
}
this.cache.set(entity.id, entity)
if (entity?.parentId) {
this._updateParentUsage(entity.parentId, usage)
}
}
private async _saveToFile(spans: SpanEntity[], traceId: string, topicId: string) {
const dirPath = path.join(this.fileDir, topicId)
await this._checkFolder(dirPath)
const filePath = path.join(dirPath, traceId)
const writeOperations = spans
.filter((span) => span.topicId)
.map(async (span) => {
await fs.appendFile(filePath, JSON.stringify(span) + '\n')
})
await Promise.all(writeOperations)
}
private async _getHisData(topicId: string, traceId: string, modelName?: string) {
const filePath = path.join(this.fileDir, topicId, traceId)
if (!(await this._existFile(filePath))) {
return []
}
try {
const fileHandle = await fs.open(filePath, 'r')
const stream = fileHandle.createReadStream()
const chunks: string[] = []
for await (const chunk of stream) {
chunks.push(chunk.toString())
}
await fileHandle.close()
// 使用生成器逐行处理
const parseLines = function* (text: string) {
for (const line of text.split('\n')) {
const trimmed = line.trim()
if (trimmed) {
try {
yield JSON.parse(trimmed) as SpanEntity
} catch (e) {
console.error(`JSON解析失败: ${trimmed}`, e)
}
}
}
}
return Array.from(parseLines(chunks.join('')))
.filter((span) => span.topicId === topicId && span.traceId === traceId && span.modelName)
.filter((span) => !modelName || span.modelName === modelName)
} catch (err) {
console.error('Error parsing JSON:', err)
throw err
}
}
private async _checkFolder(filePath: string) {
try {
await fs.mkdir(filePath, { recursive: true })
} catch (err) {
if (typeof err === 'object' && err && 'code' in err && err.code !== 'EEXIST') throw err
}
}
private async _existFile(filePath: string) {
try {
await fs.access(filePath)
return true
} catch (err) {
console.log('delete trace file error:', err)
return false
}
}
}
export const spanCacheService = new SpanCacheService()
export const cleanTopic = spanCacheService.cleanTopic.bind(spanCacheService)
export const saveEntity = spanCacheService.saveEntity.bind(spanCacheService)
export const getEntity = spanCacheService.getEntity.bind(spanCacheService)
export const tokenUsage = spanCacheService.updateTokenUsage.bind(spanCacheService)
export const saveSpans = spanCacheService.saveSpans.bind(spanCacheService)
export const getSpans = spanCacheService.getSpans.bind(spanCacheService)
export const addEndMessage = spanCacheService.setEndMessage.bind(spanCacheService)
export const bindTopic = spanCacheService.setTopicId.bind(spanCacheService)
export const addStreamMessage = spanCacheService.addStreamMessage.bind(spanCacheService)
export const cleanHistoryTrace = spanCacheService.cleanHistoryTrace.bind(spanCacheService)
export const cleanLocalData = spanCacheService.cleanLocalData.bind(spanCacheService)

View File

@ -1,5 +1,7 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload' import { electronAPI } from '@electron-toolkit/preload'
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { UpgradeChannel } from '@shared/config/constant' import { UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/types' import type { LogLevel, LogSourceWithContext } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
@ -26,6 +28,14 @@ import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav' import { CreateDirectoryOptions } from 'webdav'
import type { ActionItem } from '../renderer/src/types/selectionTypes' import type { ActionItem } from '../renderer/src/types/selectionTypes'
export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) {
if (spanContext) {
const data = { type: 'trace', context: spanContext }
console.log(`tracedInvoke data`, data)
return ipcRenderer.invoke(channel, ...args, data)
}
return ipcRenderer.invoke(channel, ...args)
}
// Custom APIs for renderer // Custom APIs for renderer
const api = { const api = {
@ -125,7 +135,7 @@ const api = {
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath), deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
read: (fileId: string, detectEncoding?: boolean) => read: (fileId: string, detectEncoding?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding), ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear), clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext),
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath), get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
/** /**
* *
@ -145,7 +155,7 @@ const api = {
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path), openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: any) => save: (path: string, content: string | NodeJS.ArrayBufferView, options?: any) =>
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options), ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder), selectFolder: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_SelectFolder, spanContext),
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data), saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId), binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId), base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
@ -169,7 +179,8 @@ const api = {
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts) update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts)
}, },
knowledgeBase: { knowledgeBase: {
create: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Create, base), create: (base: KnowledgeBaseParams, context?: SpanContext) =>
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base), reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id), delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
add: ({ add: ({
@ -185,10 +196,12 @@ const api = {
}) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload, userId }), }) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload, userId }),
remove: ({ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }) => remove: ({ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }) =>
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Remove, { uniqueId, uniqueIds, base }), ipcRenderer.invoke(IpcChannel.KnowledgeBase_Remove, { uniqueId, uniqueIds, base }),
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) => search: ({ search, base }: { search: string; base: KnowledgeBaseParams }, context?: SpanContext) =>
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Search, { search, base }), tracedInvoke(IpcChannel.KnowledgeBase_Search, context, { search, base }),
rerank: ({ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }) => rerank: (
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results }), { search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] },
context?: SpanContext
) => tracedInvoke(IpcChannel.KnowledgeBase_Rerank, context, { search, base, results }),
checkQuota: ({ base, userId }: { base: KnowledgeBaseParams; userId: string }) => checkQuota: ({ base, userId }: { base: KnowledgeBaseParams; userId: string }) =>
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Check_Quota, base, userId) ipcRenderer.invoke(IpcChannel.KnowledgeBase_Check_Quota, base, userId)
}, },
@ -253,9 +266,11 @@ const api = {
removeServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RemoveServer, server), removeServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RemoveServer, server),
restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server), restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server),
stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server), stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server),
listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server), listTools: (server: MCPServer, context?: SpanContext) => tracedInvoke(IpcChannel.Mcp_ListTools, context, server),
callTool: ({ server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string }) => callTool: (
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args, callId }), { server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string },
context?: SpanContext
) => tracedInvoke(IpcChannel.Mcp_CallTool, context, { server, name, args, callId }),
listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server), listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server),
getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) => getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }), ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }),
@ -348,7 +363,28 @@ const api = {
}, },
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text), quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text),
setDisableHardwareAcceleration: (isDisable: boolean) => setDisableHardwareAcceleration: (isDisable: boolean) =>
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable) ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable),
trace: {
saveData: (topicId: string) => ipcRenderer.invoke(IpcChannel.TRACE_SAVE_DATA, topicId),
getData: (topicId: string, traceId: string, modelName?: string) =>
ipcRenderer.invoke(IpcChannel.TRACE_GET_DATA, topicId, traceId, modelName),
saveEntity: (entity: SpanEntity) => ipcRenderer.invoke(IpcChannel.TRACE_SAVE_ENTITY, entity),
getEntity: (spanId: string) => ipcRenderer.invoke(IpcChannel.TRACE_GET_ENTITY, spanId),
bindTopic: (topicId: string, traceId: string) => ipcRenderer.invoke(IpcChannel.TRACE_BIND_TOPIC, topicId, traceId),
tokenUsage: (spanId: string, usage: TokenUsage) => ipcRenderer.invoke(IpcChannel.TRACE_TOKEN_USAGE, spanId, usage),
cleanHistory: (topicId: string, traceId: string, modelName?: string) =>
ipcRenderer.invoke(IpcChannel.TRACE_CLEAN_HISTORY, topicId, traceId, modelName),
cleanTopic: (topicId: string, traceId?: string) =>
ipcRenderer.invoke(IpcChannel.TRACE_CLEAN_TOPIC, topicId, traceId),
openWindow: (topicId: string, traceId: string, autoOpen?: boolean, modelName?: string) =>
ipcRenderer.invoke(IpcChannel.TRACE_OPEN_WINDOW, topicId, traceId, autoOpen, modelName),
setTraceWindowTitle: (title: string) => ipcRenderer.invoke(IpcChannel.TRACE_SET_TITLE, title),
addEndMessage: (spanId: string, modelName: string, context: string) =>
ipcRenderer.invoke(IpcChannel.TRACE_ADD_END_MESSAGE, spanId, modelName, context),
cleanLocalData: () => ipcRenderer.invoke(IpcChannel.TRACE_CLEAN_LOCAL_DATA),
addStreamMessage: (spanId: string, modelName: string, context: string, message: any) =>
ipcRenderer.invoke(IpcChannel.TRACE_ADD_STREAM_MESSAGE, spanId, modelName, context, message)
}
} }
// Use `contextBridge` APIs to expose Electron APIs to // Use `contextBridge` APIs to expose Electron APIs to

View File

@ -543,7 +543,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
const sdkParams: OpenAISdkParams = streamOutput const sdkParams: OpenAISdkParams = streamOutput
? { ? {
...commonParams, ...commonParams,
stream: true stream: true,
stream_options: { include_usage: true }
} }
: { : {
...commonParams, ...commonParams,

View File

@ -2,9 +2,11 @@ import { loggerService } from '@logger'
import { ApiClientFactory } from '@renderer/aiCore/clients/ApiClientFactory' import { ApiClientFactory } from '@renderer/aiCore/clients/ApiClientFactory'
import { BaseApiClient } from '@renderer/aiCore/clients/BaseApiClient' import { BaseApiClient } from '@renderer/aiCore/clients/BaseApiClient'
import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@renderer/config/models' import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@renderer/config/models'
import { withSpanResult } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import { getProviderByModel } from '@renderer/services/AssistantService' import { getProviderByModel } from '@renderer/services/AssistantService'
import type { GenerateImageParams, Model, Provider } from '@renderer/types' import type { GenerateImageParams, Model, Provider } from '@renderer/types'
import { RequestOptions, SdkModel } from '@renderer/types/sdk' import type { RequestOptions, SdkModel } from '@renderer/types/sdk'
import { isEnabledToolUse } from '@renderer/utils/mcp-tools' import { isEnabledToolUse } from '@renderer/utils/mcp-tools'
import { OpenAIAPIClient } from './clients' import { OpenAIAPIClient } from './clients'
@ -25,7 +27,7 @@ import { MIDDLEWARE_NAME as ImageGenerationMiddlewareName } from './middleware/f
import { MIDDLEWARE_NAME as ThinkingTagExtractionMiddlewareName } from './middleware/feat/ThinkingTagExtractionMiddleware' import { MIDDLEWARE_NAME as ThinkingTagExtractionMiddlewareName } from './middleware/feat/ThinkingTagExtractionMiddleware'
import { MIDDLEWARE_NAME as ToolUseExtractionMiddlewareName } from './middleware/feat/ToolUseExtractionMiddleware' import { MIDDLEWARE_NAME as ToolUseExtractionMiddlewareName } from './middleware/feat/ToolUseExtractionMiddleware'
import { MiddlewareRegistry } from './middleware/register' import { MiddlewareRegistry } from './middleware/register'
import { CompletionsParams, CompletionsResult } from './middleware/schemas' import type { CompletionsParams, CompletionsResult } from './middleware/schemas'
const logger = loggerService.withContext('AiProvider') const logger = loggerService.withContext('AiProvider')
@ -126,7 +128,23 @@ export default class AiProvider {
const wrappedCompletionMethod = applyCompletionsMiddlewares(client, client.createCompletions, middlewares) const wrappedCompletionMethod = applyCompletionsMiddlewares(client, client.createCompletions, middlewares)
// 4. Execute the wrapped method with the original params // 4. Execute the wrapped method with the original params
return wrappedCompletionMethod(params, options) const result = wrappedCompletionMethod(params, options)
return result
}
public async completionsForTrace(params: CompletionsParams, options?: RequestOptions): Promise<CompletionsResult> {
const traceName = params.assistant.model?.name
? `${params.assistant.model?.name}.${params.callType}`
: `LLM.${params.callType}`
const traceParams: StartSpanParams = {
name: traceName,
tag: 'LLM',
topicId: params.topicId || '',
modelName: params.assistant.model?.name
}
return await withSpanResult(this.completions.bind(this), traceParams, params, options)
} }
public async models(): Promise<SdkModel[]> { public async models(): Promise<SdkModel[]> {

View File

@ -1,3 +1,4 @@
import { withSpanResult } from '@renderer/services/SpanManagerService'
import { import {
RequestOptions, RequestOptions,
SdkInstance, SdkInstance,
@ -252,19 +253,28 @@ export function applyCompletionsMiddlewares<
const abortSignal = context._internal.flowControl?.abortSignal const abortSignal = context._internal.flowControl?.abortSignal
const timeout = context._internal.customState?.sdkMetadata?.timeout const timeout = context._internal.customState?.sdkMetadata?.timeout
const methodCall = async (payload) => {
return await originalCompletionsMethod.call(originalApiClientInstance, payload, {
...options,
signal: abortSignal,
timeout
})
}
const traceParams = {
name: `${params.assistant?.model?.name}.client`,
tag: 'LLM',
topicId: params.topicId || '',
modelName: params.assistant?.model?.name
}
// Call the original SDK method with transformed parameters // Call the original SDK method with transformed parameters
// 使用转换后的参数调用原始 SDK 方法 // 使用转换后的参数调用原始 SDK 方法
const rawOutput = await originalCompletionsMethod.call(originalApiClientInstance, sdkPayload, { const rawOutput = await withSpanResult(methodCall, traceParams, sdkPayload)
...options,
signal: abortSignal,
timeout
})
// Return result wrapped in CompletionsResult format // Return result wrapped in CompletionsResult format
// 以 CompletionsResult 格式返回包装的结果 // 以 CompletionsResult 格式返回包装的结果
return { return { rawOutput } as CompletionsResult
rawOutput
} as CompletionsResult
} }
const chain = middlewares.map((middleware) => middleware(api)) const chain = middlewares.map((middleware) => middleware(api))

View File

@ -119,7 +119,8 @@ function createToolHandlingTransform(
mcpTools, mcpTools,
allToolResponses, allToolResponses,
currentParams.onChunk, currentParams.onChunk,
currentParams.assistant.model! currentParams.assistant.model!,
currentParams.topicId
) )
// 缓存执行结果 // 缓存执行结果
@ -147,7 +148,8 @@ function createToolHandlingTransform(
mcpTools, mcpTools,
allToolResponses, allToolResponses,
currentParams.onChunk, currentParams.onChunk,
currentParams.assistant.model! currentParams.assistant.model!,
currentParams.topicId
) )
// 缓存执行结果 // 缓存执行结果
@ -217,7 +219,8 @@ async function executeToolCalls(
mcpTools: MCPTool[], mcpTools: MCPTool[],
allToolResponses: MCPToolResponse[], allToolResponses: MCPToolResponse[],
onChunk: CompletionsParams['onChunk'], onChunk: CompletionsParams['onChunk'],
model: Model model: Model,
topicId?: string
): Promise<{ toolResults: SdkMessageParam[]; confirmedToolCalls: SdkToolCall[] }> { ): Promise<{ toolResults: SdkMessageParam[]; confirmedToolCalls: SdkToolCall[] }> {
const mcpToolResponses: ToolCallResponse[] = toolCalls const mcpToolResponses: ToolCallResponse[] = toolCalls
.map((toolCall) => { .map((toolCall) => {
@ -244,7 +247,8 @@ async function executeToolCalls(
}, },
model, model,
mcpTools, mcpTools,
ctx._internal?.flowControl?.abortSignal ctx._internal?.flowControl?.abortSignal,
topicId
) )
// 找出已确认工具对应的原始toolCalls // 找出已确认工具对应的原始toolCalls
@ -275,7 +279,8 @@ async function executeToolUseResponses(
mcpTools: MCPTool[], mcpTools: MCPTool[],
allToolResponses: MCPToolResponse[], allToolResponses: MCPToolResponse[],
onChunk: CompletionsParams['onChunk'], onChunk: CompletionsParams['onChunk'],
model: Model model: Model,
topicId?: CompletionsParams['topicId']
): Promise<{ toolResults: SdkMessageParam[] }> { ): Promise<{ toolResults: SdkMessageParam[] }> {
// 直接使用parseAndCallTools函数处理已经解析好的ToolUseResponse // 直接使用parseAndCallTools函数处理已经解析好的ToolUseResponse
const { toolResults } = await parseAndCallTools( const { toolResults } = await parseAndCallTools(
@ -287,7 +292,8 @@ async function executeToolUseResponses(
}, },
model, model,
mcpTools, mcpTools,
ctx._internal?.flowControl?.abortSignal ctx._internal?.flowControl?.abortSignal,
topicId
) )
return { toolResults } return { toolResults }

View File

@ -55,6 +55,7 @@ export interface CompletionsParams {
// 上下文控制 // 上下文控制
contextCount?: number contextCount?: number
topicId?: string // 主题ID用于关联上下文
_internal?: ProcessingState _internal?: ProcessingState
} }

View File

@ -73,7 +73,6 @@ db.version(7)
message_blocks: 'id, messageId, file.id' // Correct syntax with comma separator message_blocks: 'id, messageId, file.id' // Correct syntax with comma separator
}) })
.upgrade((tx) => upgradeToV7(tx)) .upgrade((tx) => upgradeToV7(tx))
db.version(8) db.version(8)
.stores({ .stores({
// Re-declare all tables for the new version // Re-declare all tables for the new version

View File

@ -1,6 +1,7 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { createSelector } from '@reduxjs/toolkit' import { createSelector } from '@reduxjs/toolkit'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { appendTrace, pauseTrace, restartTrace } from '@renderer/services/SpanManagerService'
import { estimateUserPromptUsage } from '@renderer/services/TokenService' import { estimateUserPromptUsage } from '@renderer/services/TokenService'
import store, { type RootState, useAppDispatch, useAppSelector } from '@renderer/store' import store, { type RootState, useAppDispatch, useAppSelector } from '@renderer/store'
import { updateOneBlock } from '@renderer/store/messageBlock' import { updateOneBlock } from '@renderer/store/messageBlock'
@ -53,8 +54,9 @@ export function useMessageOperations(topic: Topic) {
* Dispatches deleteSingleMessageThunk. * Dispatches deleteSingleMessageThunk.
*/ */
const deleteMessage = useCallback( const deleteMessage = useCallback(
async (id: string) => { async (id: string, traceId?: string, modelName?: string) => {
await dispatch(deleteSingleMessageThunk(topic.id, id)) await dispatch(deleteSingleMessageThunk(topic.id, id))
window.api.trace.cleanHistory(topic.id, traceId || '', modelName)
}, },
[dispatch, topic.id] [dispatch, topic.id]
) )
@ -99,6 +101,7 @@ export function useMessageOperations(topic: Topic) {
*/ */
const resendMessage = useCallback( const resendMessage = useCallback(
async (message: Message, assistant: Assistant) => { async (message: Message, assistant: Assistant) => {
await restartTrace(message)
await dispatch(resendMessageThunk(topic.id, message, assistant)) await dispatch(resendMessageThunk(topic.id, message, assistant))
}, },
[dispatch, topic.id] [dispatch, topic.id]
@ -139,6 +142,7 @@ export function useMessageOperations(topic: Topic) {
for (const askId of askIds) { for (const askId of askIds) {
abortCompletion(askId) abortCompletion(askId)
} }
pauseTrace(topic.id)
dispatch(newMessagesActions.setTopicLoading({ topicId: topic.id, loading: false })) dispatch(newMessagesActions.setTopicLoading({ topicId: topic.id, loading: false }))
}, [topic.id, dispatch]) }, [topic.id, dispatch])
@ -158,6 +162,7 @@ export function useMessageOperations(topic: Topic) {
*/ */
const regenerateAssistantMessage = useCallback( const regenerateAssistantMessage = useCallback(
async (message: Message, assistant: Assistant) => { async (message: Message, assistant: Assistant) => {
await restartTrace(message)
if (message.role !== 'assistant') { if (message.role !== 'assistant') {
logger.warn('regenerateAssistantMessage should only be called for assistant messages.') logger.warn('regenerateAssistantMessage should only be called for assistant messages.')
return return
@ -173,6 +178,7 @@ export function useMessageOperations(topic: Topic) {
*/ */
const appendAssistantResponse = useCallback( const appendAssistantResponse = useCallback(
async (existingAssistantMessage: Message, newModel: Model, assistant: Assistant) => { async (existingAssistantMessage: Message, newModel: Model, assistant: Assistant) => {
await appendTrace(existingAssistantMessage, newModel)
if (existingAssistantMessage.role !== 'assistant') { if (existingAssistantMessage.role !== 'assistant') {
logger.error('appendAssistantResponse should only be called for an existing assistant message.') logger.error('appendAssistantResponse should only be called for an existing assistant message.')
return return
@ -181,7 +187,15 @@ export function useMessageOperations(topic: Topic) {
logger.error('Cannot append response: The existing assistant message is missing its askId.') logger.error('Cannot append response: The existing assistant message is missing its askId.')
return return
} }
await dispatch(appendAssistantResponseThunk(topic.id, existingAssistantMessage.id, newModel, assistant)) await dispatch(
appendAssistantResponseThunk(
topic.id,
existingAssistantMessage.id,
newModel,
assistant,
existingAssistantMessage.traceId
)
)
}, },
[dispatch, topic.id] [dispatch, topic.id]
) )
@ -375,6 +389,8 @@ export function useMessageOperations(topic: Topic) {
return return
} }
await restartTrace(message, mainTextBlock.content)
const fileBlocks = editedBlocks.filter( const fileBlocks = editedBlocks.filter(
(block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE (block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE
) )

View File

@ -2561,9 +2561,132 @@
}, },
"words": { "words": {
"knowledgeGraph": "Knowledge Graph", "knowledgeGraph": "Knowledge Graph",
"quit": "[to be translated]:退出", "quit": "Quit",
"show_window": "Show Window", "show_window": "Show Window",
"visualization": "Visualization" "visualization": "Visualization"
},
"memory": {
"title": "Memories",
"actions": "Actions",
"description": "Memory allows you to store and manage information about your interactions with the assistant. You can add, edit, and delete memories, as well as filter and search through them.",
"add_memory": "Add Memory",
"edit_memory": "Edit Memory",
"memory_content": "Memory Content",
"please_enter_memory": "Please enter memory content",
"memory_placeholder": "Enter memory content...",
"user_id": "User ID",
"user_id_placeholder": "Enter user ID (optional)",
"load_failed": "Failed to load memories",
"add_success": "Memory added successfully",
"add_failed": "Failed to add memory",
"update_success": "Memory updated successfully",
"update_failed": "Failed to update memory",
"delete_success": "Memory deleted successfully",
"delete_failed": "Failed to delete memory",
"delete_confirm_title": "Delete Memories",
"delete_confirm_content": "Are you sure you want to delete {{count}} memories?",
"delete_confirm": "Are you sure you want to delete this memory?",
"time": "Time",
"user": "User",
"content": "Content",
"score": "Score",
"memories_description": "Showing {{count}} of {{total}} memories",
"search_placeholder": "Search memories...",
"start_date": "Start Date",
"end_date": "End Date",
"all_users": "All Users",
"users": "users",
"delete_selected": "Delete Selected",
"reset_filters": "Reset Filters",
"pagination_total": "{{start}}-{{end}} of {{total}} items",
"current_user": "Current User",
"select_user": "Select User",
"default_user": "Default User",
"switch_user": "Switch User",
"user_switched": "User context switched to {{user}}",
"switch_user_confirm": "Switch user context to {{user}}?",
"add_user": "Add User",
"add_new_user": "Add New User",
"new_user_id": "New User ID",
"new_user_id_placeholder": "Enter a unique user ID",
"user_id_required": "User ID is required",
"user_id_reserved": "'default-user' is reserved, please use a different ID",
"user_id_exists": "This user ID already exists",
"user_id_too_long": "User ID cannot exceed 50 characters",
"user_id_invalid_chars": "User ID can only contain letters, numbers, hyphens and underscores",
"user_id_rules": "User ID must be unique and contain only letters, numbers, hyphens (-) and underscores (_)",
"user_created": "User {{user}} created and switched successfully",
"add_user_failed": "Failed to add user",
"memory": "memory",
"reset_user_memories": "Reset User Memories",
"reset_memories": "Reset Memories",
"delete_user": "Delete User",
"loading_memories": "Loading memories...",
"no_memories": "No memories yet",
"no_matching_memories": "No matching memories found",
"no_memories_description": "Start by adding your first memory to get started",
"try_different_filters": "Try adjusting your search criteria",
"add_first_memory": "Add Your First Memory",
"user_switch_failed": "Failed to switch user",
"cannot_delete_default_user": "Cannot delete the default user",
"delete_user_confirm_title": "Delete User",
"delete_user_confirm_content": "Are you sure you want to delete user {{user}} and all their memories?",
"user_deleted": "User {{user}} deleted successfully",
"delete_user_failed": "Failed to delete user",
"reset_user_memories_confirm_title": "Reset User Memories",
"reset_user_memories_confirm_content": "Are you sure you want to reset all memories for {{user}}?",
"user_memories_reset": "All memories for {{user}} have been reset",
"reset_user_memories_failed": "Failed to reset user memories",
"reset_memories_confirm_title": "Reset All Memories",
"reset_memories_confirm_content": "Are you sure you want to permanently delete all memories for {{user}}? This action cannot be undone.",
"memories_reset_success": "All memories for {{user}} have been reset successfully",
"reset_memories_failed": "Failed to reset memories",
"delete_confirm_single": "Are you sure you want to delete this memory?",
"total_memories": "total memories",
"default": "Default",
"custom": "Custom",
"global_memory_enabled": "Global memory enabled",
"global_memory": "Global Memory",
"enable_global_memory_first": "Please enable global memory first",
"configure_memory_first": "Please configure memory settings first",
"global_memory_disabled_title": "Global Memory Disabled",
"global_memory_disabled_desc": "To use memory features, please enable global memory in assistant settings first.",
"not_configured_title": "Memory Not Configured",
"not_configured_desc": "Please configure embedding and LLM models in memory settings to enable memory functionality.",
"go_to_memory_page": "Go to Memory Page",
"settings": "Settings",
"user_management": "User Management",
"statistics": "Statistics",
"search": "Search",
"initial_memory_content": "Welcome! This is your first memory.",
"loading": "Loading memories...",
"settings_title": "Memory Settings",
"llm_model": "LLM Model",
"please_select_llm_model": "Please select an LLM model",
"select_llm_model_placeholder": "Select LLM Model",
"embedding_model": "Embedding Model",
"please_select_embedding_model": "Please select an embedding model",
"select_embedding_model_placeholder": "Select Embedding Model",
"embedding_dimensions": "Embedding Dimensions",
"stored_memories": "Stored Memories",
"global_memory_description": "To use memory features, please enable global memory in assistant settings."
},
"trace": {
"label": "Call Chain",
"traceWindow": "Call Chain Window",
"backList": "Back To List",
"spanDetail": "Span Details",
"name": "Node Name",
"tag": "Tag",
"startTime": "Start Time",
"endTime": "End Time",
"tokenUsage": "Token Usage",
"spendTime": "Spend Time",
"parentId": "Parent Id",
"inputs": "Inputs",
"outputs": "Outputs",
"noTraceList": "No trace information found",
"edasSupport": "Powered by Alibaba Cloud EDAS"
} }
} }
} }

View File

@ -2561,9 +2561,132 @@
}, },
"words": { "words": {
"knowledgeGraph": "ナレッジグラフ", "knowledgeGraph": "ナレッジグラフ",
"quit": "[to be translated]:退出", "quit": "終了",
"show_window": "ウィンドウを表示", "show_window": "ウィンドウを表示",
"visualization": "可視化" "visualization": "可視化"
},
"trace": {
"label": "呼び出しチェーン",
"traceWindow": "呼び出しチェーンウィンドウ",
"backList": "リストに戻る",
"spanDetail": "スパンの詳細",
"name": "ノード名",
"tag": "Tagラベル",
"startTime": "開始時間",
"endTime": "終了時間",
"tokenUsage": "トークンの使用",
"spendTime": "時間を過ごす",
"parentId": "親ID",
"inputs": "入力",
"outputs": "出力",
"noTraceList": "トレース情報が見つかりません",
"edasSupport": "Powered by Alibaba Cloud EDAS"
},
"memory": {
"title": "グローバルメモリ",
"add_memory": "メモリーを追加",
"edit_memory": "メモリーを編集",
"memory_content": "メモリー内容",
"please_enter_memory": "メモリー内容を入力してください",
"memory_placeholder": "メモリー内容を入力...",
"user_id": "ユーザーID",
"user_id_placeholder": "ユーザーIDを入力オプション",
"load_failed": "メモリーの読み込みに失敗しました",
"add_success": "メモリーが正常に追加されました",
"add_failed": "メモリーの追加に失敗しました",
"update_success": "メモリーが正常に更新されました",
"update_failed": "メモリーの更新に失敗しました",
"delete_success": "メモリーが正常に削除されました",
"delete_failed": "メモリーの削除に失敗しました",
"delete_confirm_title": "メモリーを削除",
"delete_confirm_content": "{{count}}件のメモリーを削除してもよろしいですか?",
"delete_confirm": "このメモリーを削除してもよろしいですか?",
"time": "時間",
"user": "ユーザー",
"content": "内容",
"score": "スコア",
"memories_description": "{{total}}件中{{count}}件のメモリーを表示",
"search_placeholder": "メモリーを検索...",
"start_date": "開始日",
"end_date": "終了日",
"all_users": "すべてのユーザー",
"users": "ユーザー",
"delete_selected": "選択したものを削除",
"reset_filters": "フィルターをリセット",
"pagination_total": "{{total}}件中{{start}}-{{end}}件",
"current_user": "現在のユーザー",
"select_user": "ユーザーを選択",
"default_user": "デフォルトユーザー",
"switch_user": "ユーザーを切り替え",
"user_switched": "ユーザーコンテキストが{{user}}に切り替わりました",
"switch_user_confirm": "ユーザーコンテキストを{{user}}に切り替えますか?",
"add_user": "ユーザーを追加",
"add_new_user": "新しいユーザーを追加",
"new_user_id": "新しいユーザーID",
"new_user_id_placeholder": "一意のユーザーIDを入力",
"user_id_required": "ユーザーIDは必須です",
"user_id_reserved": "'default-user'は予約済みです。別のIDを使用してください",
"user_id_exists": "このユーザーIDはすでに存在します",
"user_id_too_long": "ユーザーIDは50文字を超えられません",
"user_id_invalid_chars": "ユーザーIDには文字、数字、ハイフン、アンダースコアのみ使用できます",
"user_id_rules": "ユーザーIDは一意であり、文字、数字、ハイフン(-)、アンダースコア(_)のみ含む必要があります",
"user_created": "ユーザー{{user}}が作成され、切り替えが成功しました",
"add_user_failed": "ユーザーの追加に失敗しました",
"memory": "個のメモリ",
"reset_user_memories": "ユーザーメモリをリセット",
"reset_memories": "メモリをリセット",
"delete_user": "ユーザーを削除",
"loading_memories": "メモリを読み込み中...",
"no_memories": "メモリがありません",
"no_matching_memories": "一致するメモリが見つかりません",
"no_memories_description": "最初のメモリを追加してください",
"try_different_filters": "検索条件を調整してください",
"add_first_memory": "最初のメモリを追加",
"user_switch_failed": "ユーザーの切り替えに失敗しました",
"cannot_delete_default_user": "デフォルトユーザーは削除できません",
"delete_user_confirm_title": "ユーザーを削除",
"delete_user_confirm_content": "ユーザー{{user}}とそのすべてのメモリを削除してもよろしいですか?",
"user_deleted": "ユーザー{{user}}が正常に削除されました",
"delete_user_failed": "ユーザーの削除に失敗しました",
"reset_user_memories_confirm_title": "ユーザーメモリをリセット",
"reset_user_memories_confirm_content": "{{user}}のすべてのメモリをリセットしてもよろしいですか?",
"user_memories_reset": "{{user}}のすべてのメモリがリセットされました",
"reset_user_memories_failed": "ユーザーメモリのリセットに失敗しました",
"reset_memories_confirm_title": "すべてのメモリをリセット",
"reset_memories_confirm_content": "{{user}}のすべてのメモリを完全に削除してもよろしいですか?この操作は元に戻せません。",
"memories_reset_success": "{{user}}のすべてのメモリが正常にリセットされました",
"reset_memories_failed": "メモリのリセットに失敗しました",
"delete_confirm_single": "このメモリを削除してもよろしいですか?",
"total_memories": "個のメモリ",
"default": "デフォルト",
"custom": "カスタム",
"description": "メモリは、アシスタントとのやりとりに関する情報を保存・管理する機能です。メモリの追加、編集、削除のほか、フィルタリングや検索を行うことができます。",
"global_memory_enabled": "グローバルメモリが有効化されました",
"global_memory": "グローバルメモリ",
"enable_global_memory_first": "最初にグローバルメモリを有効にしてください",
"configure_memory_first": "最初にメモリ設定を構成してください",
"global_memory_disabled_title": "グローバルメモリが無効です",
"global_memory_disabled_desc": "メモリ機能を使用するには、まずアシスタント設定でグローバルメモリを有効にしてください。",
"not_configured_title": "メモリが設定されていません",
"not_configured_desc": "メモリ機能を有効にするには、メモリ設定で埋め込みとLLMモデルを設定してください。",
"go_to_memory_page": "メモリページに移動",
"settings": "設定",
"statistics": "統計",
"search": "検索",
"actions": "アクション",
"user_management": "ユーザー管理",
"initial_memory_content": "ようこそ!これはあなたの最初の記憶です。",
"loading": "思い出を読み込み中...",
"settings_title": "メモリ設定",
"llm_model": "LLMモデル",
"please_select_llm_model": "LLMモデルを選択してください",
"select_llm_model_placeholder": "LLMモデルを選択",
"embedding_model": "埋め込みモデル",
"please_select_embedding_model": "埋め込みモデルを選択してください",
"select_embedding_model_placeholder": "埋め込みモデルを選択",
"embedding_dimensions": "埋め込み次元",
"stored_memories": "保存された記憶",
"global_memory_description": "メモリ機能を使用するには、アシスタント設定でグローバルメモリを有効にしてください。"
} }
} }
} }

View File

@ -2561,9 +2561,132 @@
}, },
"words": { "words": {
"knowledgeGraph": "Граф знаний", "knowledgeGraph": "Граф знаний",
"quit": "[to be translated]:退出", "quit": "Выйти",
"show_window": "Показать окно", "show_window": "Показать окно",
"visualization": "Визуализация" "visualization": "Визуализация"
},
"memory": {
"title": "Глобальная память",
"add_memory": "Добавить память",
"edit_memory": "Редактировать память",
"memory_content": "Содержимое памяти",
"please_enter_memory": "Пожалуйста, введите содержимое памяти",
"memory_placeholder": "Введите содержимое памяти...",
"user_id": "ID пользователя",
"user_id_placeholder": "Введите ID пользователя (необязательно)",
"load_failed": "Не удалось загрузить память",
"add_success": "Память успешно добавлена",
"add_failed": "Не удалось добавить память",
"update_success": "Память успешно обновлена",
"update_failed": "Не удалось обновить память",
"delete_success": "Память успешно удалена",
"delete_failed": "Не удалось удалить память",
"delete_confirm_title": "Удалить память",
"delete_confirm_content": "Вы уверены, что хотите удалить {{count}} записей памяти?",
"delete_confirm": "Вы уверены, что хотите удалить эту запись памяти?",
"time": "Время",
"user": "Пользователь",
"content": "Содержимое",
"score": "Оценка",
"memories_description": "Показано {{count}} из {{total}} записей памяти",
"search_placeholder": "Поиск памяти...",
"start_date": "Дата начала",
"end_date": "Дата окончания",
"all_users": "Все пользователи",
"users": "пользователи",
"delete_selected": "Удалить выбранные",
"reset_filters": "Сбросить фильтры",
"pagination_total": "{{start}}-{{end}} из {{total}} элементов",
"current_user": "Текущий пользователь",
"select_user": "Выбрать пользователя",
"default_user": "Пользователь по умолчанию",
"switch_user": "Переключить пользователя",
"user_switched": "Контекст пользователя переключен на {{user}}",
"switch_user_confirm": "Переключить контекст пользователя на {{user}}?",
"add_user": "Добавить пользователя",
"add_new_user": "Добавить нового пользователя",
"new_user_id": "Новый ID пользователя",
"new_user_id_placeholder": "Введите уникальный ID пользователя",
"user_id_required": "ID пользователя обязателен",
"user_id_reserved": "'default-user' зарезервирован, используйте другой ID",
"user_id_exists": "Этот ID пользователя уже существует",
"user_id_too_long": "ID пользователя не может превышать 50 символов",
"user_id_invalid_chars": "ID пользователя может содержать только буквы, цифры, дефисы и подчёркивания",
"user_id_rules": "ID пользователя должен быть уникальным и содержать только буквы, цифры, дефисы (-) и подчёркивания (_)",
"user_created": "Пользователь {{user}} создан и переключен успешно",
"add_user_failed": "Не удалось добавить пользователя",
"memory": "воспоминаний",
"reset_user_memories": "Сбросить воспоминания пользователя",
"reset_memories": "Сбросить воспоминания",
"delete_user": "Удалить пользователя",
"loading_memories": "Загрузка воспоминаний...",
"no_memories": "Нет воспоминаний",
"no_matching_memories": "Подходящие воспоминания не найдены",
"no_memories_description": "Начните с добавления вашего первого воспоминания",
"try_different_filters": "Попробуйте изменить критерии поиска",
"add_first_memory": "Добавить первое воспоминание",
"user_switch_failed": "Не удалось переключить пользователя",
"cannot_delete_default_user": "Нельзя удалить пользователя по умолчанию",
"delete_user_confirm_title": "Удалить пользователя",
"delete_user_confirm_content": "Вы уверены, что хотите удалить пользователя {{user}} и все его воспоминания?",
"user_deleted": "Пользователь {{user}} успешно удален",
"delete_user_failed": "Не удалось удалить пользователя",
"reset_user_memories_confirm_title": "Сбросить воспоминания пользователя",
"reset_user_memories_confirm_content": "Вы уверены, что хотите сбросить все воспоминания пользователя {{user}}?",
"user_memories_reset": "Все воспоминания пользователя {{user}} сброшены",
"reset_user_memories_failed": "Не удалось сбросить воспоминания пользователя",
"reset_memories_confirm_title": "Сбросить все воспоминания",
"reset_memories_confirm_content": "Вы уверены, что хотите навсегда удалить все воспоминания пользователя {{user}}? Это действие нельзя отменить.",
"memories_reset_success": "Все воспоминания пользователя {{user}} успешно сброшены",
"reset_memories_failed": "Не удалось сбросить воспоминания",
"delete_confirm_single": "Вы уверены, что хотите удалить это воспоминание?",
"total_memories": "всего воспоминаний",
"default": "По умолчанию",
"custom": "Пользовательский",
"description": "Память позволяет хранить и управлять информацией о ваших взаимодействиях с ассистентом. Вы можете добавлять, редактировать и удалять воспоминания, а также фильтровать и искать их.",
"global_memory_enabled": "Глобальная память включена",
"global_memory": "Глобальная память",
"enable_global_memory_first": "Сначала включите глобальную память",
"configure_memory_first": "Сначала настройте параметры памяти",
"global_memory_disabled_title": "Глобальная память отключена",
"global_memory_disabled_desc": "Чтобы использовать функции памяти, сначала включите глобальную память в настройках ассистента.",
"not_configured_title": "Память не настроена",
"not_configured_desc": "Пожалуйста, настройте модели встраивания и LLM в настройках памяти, чтобы включить функциональность памяти.",
"go_to_memory_page": "Перейти на страницу памяти",
"settings": "Настройки",
"statistics": "Статистика",
"search": "Поиск",
"actions": "Действия",
"user_management": "Управление пользователями",
"initial_memory_content": "Добро пожаловать! Это ваше первое воспоминание.",
"loading": "Загрузка воспоминаний...",
"settings_title": "Настройки памяти",
"llm_model": "Модель LLM",
"please_select_llm_model": "Пожалуйста, выберите модель LLM",
"select_llm_model_placeholder": "Выбор модели LLM",
"embedding_model": "Модель встраивания",
"please_select_embedding_model": "Пожалуйста, выберите модель для внедрения",
"select_embedding_model_placeholder": "Выберите модель внедрения",
"embedding_dimensions": "Размерность вложения",
"stored_memories": "Запасённые воспоминания",
"global_memory_description": "Для использования функций памяти необходимо включить глобальную память в настройках ассистента."
},
"trace": {
"label": "Цепочка вызовов",
"traceWindow": "Окно цепочки вызовов",
"backList": "Вернуться к списку",
"spanDetail": "Span Подробнее",
"name": "Имя узла",
"tag": "ярлык",
"startTime": "время начала",
"endTime": "время окончания",
"tokenUsage": "Использование токена",
"spendTime": "тратитьВремя",
"parentId": "Родительский идентификатор",
"inputs": "входы",
"outputs": "выходы",
"noTraceList": "Информация о следах не найдена",
"edasSupport": "Powered by Alibaba Cloud EDAS"
} }
} }
} }

View File

@ -2564,6 +2564,129 @@
"quit": "退出", "quit": "退出",
"show_window": "显示窗口", "show_window": "显示窗口",
"visualization": "可视化" "visualization": "可视化"
},
"memory": {
"title": "全局记忆",
"settings": "设置",
"statistics": "统计",
"search": "搜索",
"actions": "操作",
"add_memory": "添加记忆",
"edit_memory": "编辑记忆",
"memory_content": "记忆内容",
"please_enter_memory": "请输入记忆内容",
"memory_placeholder": "输入记忆内容...",
"user_id": "用户 ID",
"user_id_placeholder": "输入用户 ID可选",
"load_failed": "加载记忆失败",
"add_success": "记忆添加成功",
"add_failed": "添加记忆失败",
"update_success": "记忆更新成功",
"update_failed": "更新记忆失败",
"delete_success": "记忆删除成功",
"delete_failed": "删除记忆失败",
"delete_confirm_title": "删除记忆",
"delete_confirm_content": "确定要删除 {{count}} 条记忆吗?",
"delete_confirm": "确定要删除这条记忆吗?",
"time": "时间",
"user": "用户",
"content": "内容",
"score": "分数",
"memories_description": "显示 {{count}} / {{total}} 条记忆",
"search_placeholder": "搜索记忆...",
"start_date": "开始日期",
"end_date": "结束日期",
"all_users": "所有用户",
"users": "用户",
"delete_selected": "删除选中",
"reset_filters": "重置筛选",
"pagination_total": "第 {{start}}-{{end}} 项,共 {{total}} 项",
"current_user": "当前用户",
"select_user": "选择用户",
"default_user": "默认用户",
"switch_user": "切换用户",
"user_switched": "用户上下文已切换到 {{user}}",
"switch_user_confirm": "将用户上下文切换到 {{user}}",
"add_user": "添加用户",
"add_new_user": "添加新用户",
"new_user_id": "新用户ID",
"new_user_id_placeholder": "输入唯一的用户ID",
"user_management": "用户管理",
"user_id_required": "用户ID为必填项",
"user_id_reserved": "'default-user' 为保留字请使用其他ID",
"user_id_exists": "该用户ID已存在",
"user_id_too_long": "用户ID不能超过50个字符",
"user_id_invalid_chars": "用户ID只能包含字母、数字、连字符和下划线",
"user_id_rules": "用户ID必须唯一只能包含字母、数字、连字符(-)和下划线(_)",
"user_created": "用户 {{user}} 创建并切换成功",
"add_user_failed": "添加用户失败",
"memory": "条记忆",
"reset_user_memories": "重置用户记忆",
"reset_memories": "重置记忆",
"delete_user": "删除用户",
"loading_memories": "正在加载记忆...",
"no_memories": "暂无记忆",
"no_matching_memories": "未找到匹配的记忆",
"no_memories_description": "开始添加您的第一条记忆吧",
"try_different_filters": "尝试调整搜索条件",
"add_first_memory": "添加您的第一条记忆",
"user_switch_failed": "切换用户失败",
"cannot_delete_default_user": "不能删除默认用户",
"delete_user_confirm_title": "删除用户",
"delete_user_confirm_content": "确定要删除用户 {{user}} 及其所有记忆吗?",
"user_deleted": "用户 {{user}} 删除成功",
"delete_user_failed": "删除用户失败",
"reset_user_memories_confirm_title": "重置用户记忆",
"reset_user_memories_confirm_content": "确定要重置 {{user}} 的所有记忆吗?",
"user_memories_reset": "{{user}} 的所有记忆已重置",
"reset_user_memories_failed": "重置用户记忆失败",
"reset_memories_confirm_title": "重置所有记忆",
"reset_memories_confirm_content": "确定要永久删除 {{user}} 的所有记忆吗?此操作无法撤销。",
"memories_reset_success": "{{user}} 的所有记忆已成功重置",
"reset_memories_failed": "重置记忆失败",
"delete_confirm_single": "确定要删除这条记忆吗?",
"total_memories": "条记忆",
"default": "默认",
"custom": "自定义",
"description": "记忆功能允许您存储和管理与助手交互的信息。您可以添加、编辑和删除记忆,也可以对它们进行过滤和搜索。",
"global_memory_enabled": "全局记忆已启用",
"global_memory": "全局记忆",
"enable_global_memory_first": "请先启用全局记忆",
"configure_memory_first": "请先配置记忆设置",
"global_memory_disabled_title": "全局记忆已禁用",
"global_memory_disabled_desc": "要使用记忆功能,请先在助手设置中启用全局记忆。",
"not_configured_title": "记忆未配置",
"not_configured_desc": "请在记忆设置中配置嵌入和LLM模型以启用记忆功能。",
"go_to_memory_page": "前往记忆页面",
"initial_memory_content": "欢迎!这是您的第一条记忆。",
"loading": "正在加载记忆...",
"settings_title": "记忆设置",
"llm_model": "LLM 模型",
"please_select_llm_model": "请选择 LLM 模型",
"select_llm_model_placeholder": "选择 LLM 模型",
"embedding_model": "嵌入模型",
"please_select_embedding_model": "请选择嵌入模型",
"select_embedding_model_placeholder": "选择嵌入模型",
"embedding_dimensions": "嵌入维度",
"stored_memories": "已存储记忆",
"global_memory_description": "需要开启助手设置中的全局记忆才能使用"
},
"trace": {
"label": "调用链",
"traceWindow": "调用链窗口",
"backList": "返回列表",
"spanDetail": "Span详情",
"name": "节点名称",
"tag": "标签",
"startTime": "开始时间",
"endTime": "结束时间",
"tokenUsage": "Token使用量",
"spendTime": "消耗时间",
"parentId": "上级Id",
"inputs": "输入",
"outputs": "输出",
"noTraceList": "没有找到Trace信息",
"edasSupport": "Powered by Alibaba Cloud EDAS"
} }
} }
} }

View File

@ -2561,9 +2561,132 @@
}, },
"words": { "words": {
"knowledgeGraph": "知識圖譜", "knowledgeGraph": "知識圖譜",
"quit": "[to be translated]:退出", "quit": "結束",
"show_window": "顯示視窗", "show_window": "顯示視窗",
"visualization": "視覺化" "visualization": "視覺化"
},
"memory": {
"title": "全域記憶",
"add_memory": "新增記憶",
"edit_memory": "編輯記憶",
"memory_content": "記憶內容",
"please_enter_memory": "請輸入記憶內容",
"memory_placeholder": "輸入記憶內容...",
"user_id": "使用者ID",
"user_id_placeholder": "輸入使用者ID可選",
"load_failed": "載入記憶失敗",
"add_success": "記憶新增成功",
"add_failed": "新增記憶失敗",
"update_success": "記憶更新成功",
"update_failed": "更新記憶失敗",
"delete_success": "記憶刪除成功",
"delete_failed": "刪除記憶失敗",
"delete_confirm_title": "刪除記憶",
"delete_confirm_content": "確定要刪除 {{count}} 條記憶嗎?",
"delete_confirm": "確定要刪除這條記憶嗎?",
"time": "時間",
"user": "使用者",
"content": "內容",
"score": "分數",
"memories_description": "顯示 {{count}} / {{total}} 條記憶",
"search_placeholder": "搜尋記憶...",
"start_date": "開始日期",
"end_date": "結束日期",
"all_users": "所有使用者",
"users": "使用者",
"delete_selected": "刪除選取",
"reset_filters": "重設篩選",
"pagination_total": "第 {{start}}-{{end}} 項,共 {{total}} 項",
"current_user": "目前使用者",
"select_user": "選擇使用者",
"default_user": "預設使用者",
"switch_user": "切換使用者",
"user_switched": "使用者內容已切換至 {{user}}",
"switch_user_confirm": "將使用者內容切換至 {{user}}",
"add_user": "新增使用者",
"add_new_user": "新增新使用者",
"new_user_id": "新使用者ID",
"new_user_id_placeholder": "輸入唯一的使用者ID",
"user_id_required": "使用者ID為必填欄位",
"user_id_reserved": "'default-user' 為保留字請使用其他ID",
"user_id_exists": "此使用者ID已存在",
"user_id_too_long": "使用者ID不能超過50個字元",
"user_id_invalid_chars": "使用者ID只能包含字母、數字、連字符和底線",
"user_id_rules": "使用者ID必须唯一只能包含字母、數字、連字符(-)和底線(_)",
"user_created": "使用者 {{user}} 建立並切換成功",
"add_user_failed": "新增使用者失敗",
"memory": "個記憶",
"reset_user_memories": "重置使用者記憶",
"reset_memories": "重置記憶",
"delete_user": "刪除使用者",
"loading_memories": "正在載入記憶...",
"no_memories": "暫無記憶",
"no_matching_memories": "未找到符合的記憶",
"no_memories_description": "開始新增您的第一個記憶吧",
"try_different_filters": "嘗試調整搜尋條件",
"add_first_memory": "新增您的第一個記憶",
"user_switch_failed": "切換使用者失敗",
"cannot_delete_default_user": "不能刪除預設使用者",
"delete_user_confirm_title": "刪除使用者",
"delete_user_confirm_content": "確定要刪除使用者 {{user}} 及其所有記憶嗎?",
"user_deleted": "使用者 {{user}} 刪除成功",
"delete_user_failed": "刪除使用者失敗",
"reset_user_memories_confirm_title": "重置使用者記憶",
"reset_user_memories_confirm_content": "確定要重置 {{user}} 的所有記憶嗎?",
"user_memories_reset": "{{user}} 的所有記憶已重置",
"reset_user_memories_failed": "重置使用者記憶失敗",
"reset_memories_confirm_title": "重置所有記憶",
"reset_memories_confirm_content": "確定要永久刪除 {{user}} 的所有記憶嗎?此操作無法復原。",
"memories_reset_success": "{{user}} 的所有記憶已成功重置",
"reset_memories_failed": "重置記憶失敗",
"delete_confirm_single": "確定要刪除這個記憶嗎?",
"total_memories": "個記憶",
"default": "預設",
"custom": "自定義",
"description": "記憶功能讓您儲存和管理與助手互動的資訊。您可以新增、編輯和刪除記憶,也可以對它們進行篩選和搜尋。",
"global_memory_enabled": "全域記憶已啟用",
"global_memory": "全域記憶",
"enable_global_memory_first": "請先啟用全域記憶",
"configure_memory_first": "請先配置記憶設定",
"global_memory_disabled_title": "全域記憶已停用",
"global_memory_disabled_desc": "要使用記憶功能,請先在助手設定中啟用全域記憶。",
"not_configured_title": "記憶未配置",
"not_configured_desc": "請在記憶設定中配置嵌入和LLM模型以啟用記憶功能。",
"go_to_memory_page": "前往記憶頁面",
"settings": "設定",
"statistics": "統計",
"search": "搜尋",
"actions": "操作",
"user_management": "使用者管理",
"initial_memory_content": "歡迎!這是你的第一個記憶。",
"loading": "載入記憶中...",
"settings_title": "記憶體設定",
"llm_model": "LLM 模型",
"please_select_llm_model": "請選擇一個LLM模型",
"select_llm_model_placeholder": "選擇LLM模型",
"embedding_model": "嵌入模型",
"please_select_embedding_model": "請選擇一個嵌入模型",
"select_embedding_model_placeholder": "選擇嵌入模型",
"embedding_dimensions": "嵌入維度",
"stored_memories": "儲存的記憶",
"global_memory_description": "需要開啟助手設定中的全域記憶才能使用"
},
"trace": {
"label": "呼叫鏈",
"traceWindow": "呼叫鏈視窗",
"backList": "返回清單",
"spanDetail": "Span詳情",
"name": "節點名稱",
"tag": "標籤",
"startTime": "開始時間",
"endTime": "結束時間",
"tokenUsage": "Token使用量",
"spendTime": "消耗時間",
"parentId": "上級Id",
"inputs": "輸入",
"outputs": "輸出",
"noTraceList": "沒有找到Trace資訊",
"edasSupport": "Powered by Alibaba Cloud EDAS"
} }
} }
} }

View File

@ -341,6 +341,7 @@
"provider": "Παρέχων", "provider": "Παρέχων",
"reasoning_content": "Έχει σκεφτεί πολύ καλά", "reasoning_content": "Έχει σκεφτεί πολύ καλά",
"regenerate": "Ξαναπαραγωγή", "regenerate": "Ξαναπαραγωγή",
"trace": "ίχνος",
"rename": "Μετονομασία", "rename": "Μετονομασία",
"reset": "Επαναφορά", "reset": "Επαναφορά",
"save": "Αποθήκευση", "save": "Αποθήκευση",

View File

@ -342,6 +342,7 @@
"provider": "Proveedor", "provider": "Proveedor",
"reasoning_content": "Pensamiento profundo", "reasoning_content": "Pensamiento profundo",
"regenerate": "Regenerar", "regenerate": "Regenerar",
"trace": "Rastro",
"rename": "Renombrar", "rename": "Renombrar",
"reset": "Restablecer", "reset": "Restablecer",
"save": "Guardar", "save": "Guardar",

View File

@ -341,6 +341,7 @@
"provider": "Fournisseur", "provider": "Fournisseur",
"reasoning_content": "Réflexion approfondie", "reasoning_content": "Réflexion approfondie",
"regenerate": "Regénérer", "regenerate": "Regénérer",
"trace": "Tracer",
"rename": "Renommer", "rename": "Renommer",
"reset": "Réinitialiser", "reset": "Réinitialiser",
"save": "Enregistrer", "save": "Enregistrer",

View File

@ -343,6 +343,7 @@
"provider": "Fornecedor", "provider": "Fornecedor",
"reasoning_content": "Pensamento profundo concluído", "reasoning_content": "Pensamento profundo concluído",
"regenerate": "Regenerar", "regenerate": "Regenerar",
"trace": "Regenerar",
"rename": "Renomear", "rename": "Renomear",
"reset": "Redefinir", "reset": "Redefinir",
"save": "Salvar", "save": "Salvar",

View File

@ -4,6 +4,7 @@ import { loggerService } from '@logger'
import { startAutoSync } from './services/BackupService' import { startAutoSync } from './services/BackupService'
import { startNutstoreAutoSync } from './services/NutstoreService' import { startNutstoreAutoSync } from './services/NutstoreService'
import storeSyncService from './services/StoreSyncService' import storeSyncService from './services/StoreSyncService'
import { webTraceService } from './services/WebTraceService'
import store from './store' import store from './store'
loggerService.initWindowSource('mainWindow') loggerService.initWindowSource('mainWindow')
@ -30,6 +31,11 @@ function initStoreSync() {
storeSyncService.subscribe() storeSyncService.subscribe()
} }
function initWebTrace() {
webTraceService.init()
}
initKeyv() initKeyv()
initAutoSync() initAutoSync()
initStoreSync() initStoreSync()
initWebTrace()

View File

@ -26,6 +26,7 @@ import FileManager from '@renderer/services/FileManager'
import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService' import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService'
import { getModelUniqId } from '@renderer/services/ModelService' import { getModelUniqId } from '@renderer/services/ModelService'
import PasteService from '@renderer/services/PasteService' import PasteService from '@renderer/services/PasteService'
import { spanManagerService } from '@renderer/services/SpanManagerService'
import { estimateTextTokens as estimateTxtTokens, estimateUserPromptUsage } from '@renderer/services/TokenService' import { estimateTextTokens as estimateTxtTokens, estimateUserPromptUsage } from '@renderer/services/TokenService'
import { translateText } from '@renderer/services/TranslateService' import { translateText } from '@renderer/services/TranslateService'
import WebSearchService from '@renderer/services/WebSearchService' import WebSearchService from '@renderer/services/WebSearchService'
@ -209,7 +210,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
logger.info('Starting to send message') logger.info('Starting to send message')
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE) const parent = spanManagerService.startTrace(
{ topicId: topic.id, name: 'sendMessage', inputs: text },
mentionedModels && mentionedModels.length > 0 ? mentionedModels : [assistant.model]
)
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, { topicId: topic.id, traceId: parent?.spanContext().traceId })
try { try {
// Dispatch the sendMessage action with all options // Dispatch the sendMessage action with all options
@ -234,6 +239,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
baseUserMessage.usage = await estimateUserPromptUsage(baseUserMessage) baseUserMessage.usage = await estimateUserPromptUsage(baseUserMessage)
const { message, blocks } = getUserMessage(baseUserMessage) const { message, blocks } = getUserMessage(baseUserMessage)
message.traceId = parent?.spanContext().traceId
currentMessageId.current = message.id currentMessageId.current = message.id
dispatch(_sendMessage(message, blocks, assistantWithTopicPrompt, topic.id)) dispatch(_sendMessage(message, blocks, assistantWithTopicPrompt, topic.id))
@ -246,6 +252,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setExpend(false) setExpend(false)
} catch (error) { } catch (error) {
logger.warn('Failed to send message:', error) logger.warn('Failed to send message:', error)
parent?.recordException(error as Error)
} }
}, [assistant, dispatch, files, inputEmpty, loading, mentionedModels, resizeTextArea, text, topic]) }, [assistant, dispatch, files, inputEmpty, loading, mentionedModels, resizeTextArea, text, topic])
@ -472,7 +479,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
await onPause() await onPause()
await delay(1) await delay(1)
} }
EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES) EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES, topic)
} }
const onNewContext = () => { const onNewContext = () => {

View File

@ -1,4 +1,5 @@
import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons' import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
import { defaultConfig } from '@mcp-trace/trace-core'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup' import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
@ -14,6 +15,7 @@ import { translateText } from '@renderer/services/TranslateService'
import store, { RootState } from '@renderer/store' import store, { RootState } from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import { selectMessagesForTopic } from '@renderer/store/newMessage' import { selectMessagesForTopic } from '@renderer/store/newMessage'
import { TraceIcon } from '@renderer/trace/pages/Component'
import type { Assistant, Language, Model, Topic } from '@renderer/types' import type { Assistant, Language, Model, Topic } from '@renderer/types'
import { type Message, MessageBlockType } from '@renderer/types/newMessage' import { type Message, MessageBlockType } from '@renderer/types/newMessage'
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, classNames } from '@renderer/utils' import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, classNames } from '@renderer/utils'
@ -45,7 +47,7 @@ import {
ThumbsUp, ThumbsUp,
Trash Trash
} from 'lucide-react' } from 'lucide-react'
import { FC, memo, useCallback, useMemo, useState } from 'react' import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
@ -177,6 +179,24 @@ const MessageMenubar: FC<Props> = (props) => {
[isTranslating, message, getTranslationUpdater, mainTextContent] [isTranslating, message, getTranslationUpdater, mainTextContent]
) )
const [isDevelopModel, setIsDevelopModel] = useState(true)
useEffect(() => {
setIsDevelopModel(defaultConfig.isDevModel || false)
}, [])
const handleTraceUserMessage = useCallback(async () => {
console.log('current traceId', message.traceId, 'start send')
if (message.traceId) {
window.api.trace.openWindow(
message.topicId,
message.traceId,
true,
message.role === 'user' ? undefined : message.model?.name
)
}
}, [message])
const isEditable = useMemo(() => { const isEditable = useMemo(() => {
return findMainTextBlocks(message).length > 0 // 使用 MCP Server 后会有大于一段 MatinTextBlock return findMainTextBlocks(message).length > 0 // 使用 MCP Server 后会有大于一段 MatinTextBlock
}, [message]) }, [message])
@ -560,7 +580,7 @@ const MessageMenubar: FC<Props> = (props) => {
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />} icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onOpenChange={(open) => open && setShowDeleteTooltip(false)} onOpenChange={(open) => open && setShowDeleteTooltip(false)}
onConfirm={() => deleteMessage(message.id)}> onConfirm={() => deleteMessage(message.id, message.traceId, message.model?.name)}>
<ActionButton <ActionButton
className="message-action-button" className="message-action-button"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -574,6 +594,13 @@ const MessageMenubar: FC<Props> = (props) => {
</Tooltip> </Tooltip>
</ActionButton> </ActionButton>
</Popconfirm> </Popconfirm>
{isDevelopModel && message.traceId && (
<Tooltip title={t('trace.label')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={() => handleTraceUserMessage()}>
<TraceIcon size={16} className={'lucide lucide-trash'} />
</ActionButton>
</Tooltip>
)}
{!isUserMessage && ( {!isUserMessage && (
<Dropdown <Dropdown
menu={{ items: dropdownItems, onClick: (e) => e.domEvent.stopPropagation() }} menu={{ items: dropdownItems, onClick: (e) => e.domEvent.stopPropagation() }}

View File

@ -161,6 +161,7 @@ const DataSettings: FC = () => {
onOk: async () => { onOk: async () => {
try { try {
await window.api.clearCache() await window.api.clearCache()
await window.api.trace.cleanLocalData()
await window.api.getCacheSize().then(setCacheSize) await window.api.getCacheSize().then(setCacheSize)
window.message.success(t('settings.data.clear_cache.success')) window.message.success(t('settings.data.clear_cache.success'))
} catch (error) { } catch (error) {

View File

@ -1,4 +1,5 @@
import { WebSearchState } from '@renderer/store/websearch' import { withSpanResult } from '@renderer/services/SpanManagerService'
import type { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { filterResultWithBlacklist } from '@renderer/utils/blacklistMatchPattern' import { filterResultWithBlacklist } from '@renderer/utils/blacklistMatchPattern'
@ -7,16 +8,38 @@ import WebSearchProviderFactory from './WebSearchProviderFactory'
export default class WebSearchEngineProvider { export default class WebSearchEngineProvider {
private sdk: BaseWebSearchProvider private sdk: BaseWebSearchProvider
private providerName: string
private topicId: string | undefined
private parentSpanId: string | undefined
private modelName: string | undefined
constructor(provider: WebSearchProvider) { constructor(provider: WebSearchProvider, parentSpanId?: string) {
this.sdk = WebSearchProviderFactory.create(provider) this.sdk = WebSearchProviderFactory.create(provider)
this.providerName = provider.name
this.topicId = provider.topicId
this.parentSpanId = parentSpanId
this.modelName = provider.modelName
} }
public async search( public async search(
query: string, query: string,
websearch: WebSearchState, websearch: WebSearchState,
httpOptions?: RequestInit httpOptions?: RequestInit
): Promise<WebSearchProviderResponse> { ): Promise<WebSearchProviderResponse> {
const result = await this.sdk.search(query, websearch, httpOptions) const callSearch = async ({ query, websearch }) => {
return await this.sdk.search(query, websearch, httpOptions)
}
const traceParams = {
name: `${this.providerName}.search`,
tag: 'Web',
topicId: this.topicId || '',
parentSpanId: this.parentSpanId,
modelName: this.modelName
}
const result = await withSpanResult(callSearch, traceParams, { query, websearch })
return await filterResultWithBlacklist(result, websearch) return await filterResultWithBlacklist(result, websearch)
} }
} }

View File

@ -18,6 +18,7 @@ import {
import { getModel } from '@renderer/hooks/useModel' import { getModel } from '@renderer/hooks/useModel'
import { getStoreSetting } from '@renderer/hooks/useSettings' import { getStoreSetting } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { currentSpan, withSpanResult } from '@renderer/services/SpanManagerService'
import store from '@renderer/store' import store from '@renderer/store'
import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory' import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory'
import { import {
@ -110,11 +111,24 @@ async function fetchExternalTool(
summaryAssistant.model = assistant.model || getDefaultModel() summaryAssistant.model = assistant.model || getDefaultModel()
summaryAssistant.prompt = prompt summaryAssistant.prompt = prompt
const callSearchSummary = async (params: { messages: Message[]; assistant: Assistant }) => {
return await fetchSearchSummary(params)
}
const traceParams = {
name: `${summaryAssistant.model?.name}.Summary`,
tag: 'LLM',
topicId: lastUserMessage.topicId,
modelName: summaryAssistant.model.name
}
const searchSummaryParams = {
messages: lastAnswer ? [lastAnswer, lastUserMessage] : [lastUserMessage],
assistant: summaryAssistant
}
try { try {
const result = await fetchSearchSummary({ const result = await withSpanResult(callSearchSummary, traceParams, searchSummaryParams)
messages: lastAnswer ? [lastAnswer, lastUserMessage] : [lastUserMessage],
assistant: summaryAssistant
})
if (!result) return getFallbackResult() if (!result) return getFallbackResult()
@ -145,7 +159,10 @@ async function fetchExternalTool(
} }
// --- Web Search Function --- // --- Web Search Function ---
const searchTheWeb = async (extractResults: ExtractResults | undefined): Promise<WebSearchResponse | undefined> => { const searchTheWeb = async (
extractResults: ExtractResults | undefined,
parentSpanId?: string
): Promise<WebSearchResponse | undefined> => {
if (!shouldWebSearch) return if (!shouldWebSearch) return
// Add check for extractResults existence early // Add check for extractResults existence early
@ -165,8 +182,17 @@ async function fetchExternalTool(
try { try {
// Use the consolidated processWebsearch function // Use the consolidated processWebsearch function
WebSearchService.createAbortSignal(lastUserMessage.id) WebSearchService.createAbortSignal(lastUserMessage.id)
let safeWebSearchProvider = webSearchProvider
if (webSearchProvider) {
safeWebSearchProvider = {
...webSearchProvider,
topicId: lastUserMessage.topicId,
parentSpanId,
modelName: assistant.model.name
}
}
const webSearchResponse = await WebSearchService.processWebsearch( const webSearchResponse = await WebSearchService.processWebsearch(
webSearchProvider!, safeWebSearchProvider!,
extractResults, extractResults,
lastUserMessage.id lastUserMessage.id
) )
@ -222,7 +248,9 @@ async function fetchExternalTool(
// --- Knowledge Base Search Function --- // --- Knowledge Base Search Function ---
const searchKnowledgeBase = async ( const searchKnowledgeBase = async (
extractResults: ExtractResults | undefined extractResults: ExtractResults | undefined,
parentSpanId?: string,
modelName?: string
): Promise<KnowledgeReference[] | undefined> => { ): Promise<KnowledgeReference[] | undefined> => {
if (!hasKnowledgeBase) return if (!hasKnowledgeBase) return
@ -253,7 +281,13 @@ async function fetchExternalTool(
// const mainTextBlock = mainTextBlocks // const mainTextBlock = mainTextBlocks
// ?.map((blockId) => store.getState().messageBlocks.entities[blockId]) // ?.map((blockId) => store.getState().messageBlocks.entities[blockId])
// .find((block) => block?.type === MessageBlockType.MAIN_TEXT) as MainTextMessageBlock | undefined // .find((block) => block?.type === MessageBlockType.MAIN_TEXT) as MainTextMessageBlock | undefined
return await processKnowledgeSearch(tempExtractResults, knowledgeBaseIds) return await processKnowledgeSearch(
tempExtractResults,
knowledgeBaseIds,
lastUserMessage.topicId,
parentSpanId,
modelName
)
} catch (error) { } catch (error) {
logger.error('Knowledge base search failed:', error) logger.error('Knowledge base search failed:', error)
return return
@ -274,11 +308,12 @@ async function fetchExternalTool(
let knowledgeReferencesFromSearch: KnowledgeReference[] | undefined let knowledgeReferencesFromSearch: KnowledgeReference[] | undefined
let memorySearchReferences: MemoryItem[] | undefined let memorySearchReferences: MemoryItem[] | undefined
const parentSpanId = currentSpan(lastUserMessage.topicId, assistant.model?.name)?.spanContext().spanId
// 并行执行搜索 // 并行执行搜索
if (shouldWebSearch || shouldKnowledgeSearch || shouldSearchMemory) { if (shouldWebSearch || shouldKnowledgeSearch || shouldSearchMemory) {
;[webSearchResponseFromSearch, knowledgeReferencesFromSearch, memorySearchReferences] = await Promise.all([ ;[webSearchResponseFromSearch, knowledgeReferencesFromSearch, memorySearchReferences] = await Promise.all([
searchTheWeb(extractResults), searchTheWeb(extractResults, parentSpanId),
searchKnowledgeBase(extractResults), searchKnowledgeBase(extractResults, parentSpanId, assistant.model?.name),
searchMemory() searchMemory()
]) ])
} }
@ -319,9 +354,10 @@ async function fetchExternalTool(
if (enabledMCPs && enabledMCPs.length > 0) { if (enabledMCPs && enabledMCPs.length > 0) {
try { try {
const spanContext = currentSpan(lastUserMessage.topicId, assistant.model?.name)?.spanContext()
const toolPromises = enabledMCPs.map<Promise<MCPTool[]>>(async (mcpServer) => { const toolPromises = enabledMCPs.map<Promise<MCPTool[]>>(async (mcpServer) => {
try { try {
const tools = await window.api.mcp.listTools(mcpServer) const tools = await window.api.mcp.listTools(mcpServer, spanContext)
return tools.filter((tool: any) => !mcpServer.disabledTools?.includes(tool.name)) return tools.filter((tool: any) => !mcpServer.disabledTools?.includes(tool.name))
} catch (error) { } catch (error) {
logger.error(`Error fetching tools from MCP server ${mcpServer.name}:`, error) logger.error(`Error fetching tools from MCP server ${mcpServer.name}:`, error)
@ -417,24 +453,27 @@ export async function fetchChatCompletion({
// --- Call AI Completions --- // --- Call AI Completions ---
onChunkReceived({ type: ChunkType.LLM_RESPONSE_CREATED }) onChunkReceived({ type: ChunkType.LLM_RESPONSE_CREATED })
await AI.completions(
{ const completionsParams: CompletionsParams = {
callType: 'chat', callType: 'chat',
messages: _messages, messages: _messages,
assistant, assistant,
onChunk: onChunkReceived, onChunk: onChunkReceived,
mcpTools: mcpTools, mcpTools: mcpTools,
maxTokens, maxTokens,
streamOutput: assistant.settings?.streamOutput || false, streamOutput: assistant.settings?.streamOutput || false,
enableReasoning, enableReasoning,
enableWebSearch, enableWebSearch,
enableUrlContext, enableUrlContext,
enableGenerateImage enableGenerateImage,
}, topicId: lastUserMessage.topicId
{ }
streamOutput: assistant.settings?.streamOutput || false
} const requestOptions = {
) streamOutput: assistant.settings?.streamOutput || false
}
return await AI.completionsForTrace(completionsParams, requestOptions)
// Post-conversation memory processing // Post-conversation memory processing
const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState()) const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState())
@ -600,6 +639,8 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
const AI = new AiProvider(provider) const AI = new AiProvider(provider)
const topicId = messages?.find((message) => message.topicId)?.topicId || undefined
// LLM对多条消息的总结有问题用单条结构化的消息表示会话内容会更好 // LLM对多条消息的总结有问题用单条结构化的消息表示会话内容会更好
const structredMessages = contextMessages.map((message) => { const structredMessages = contextMessages.map((message) => {
const structredMessage = { const structredMessage = {
@ -637,11 +678,12 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
assistant: { ...summaryAssistant, prompt, model }, assistant: { ...summaryAssistant, prompt, model },
maxTokens: 1000, maxTokens: 1000,
streamOutput: false, streamOutput: false,
topicId,
enableReasoning: false enableReasoning: false
} }
try { try {
const { getText } = await AI.completions(params) const { getText } = await AI.completionsForTrace(params)
const text = getText() const text = getText()
return removeSpecialCharactersForTopicName(text) || null return removeSpecialCharactersForTopicName(text) || null
} catch (error: any) { } catch (error: any) {
@ -657,16 +699,19 @@ export async function fetchSearchSummary({ messages, assistant }: { messages: Me
return null return null
} }
const topicId = messages?.find((message) => message.topicId)?.topicId || undefined
const AI = new AiProvider(provider) const AI = new AiProvider(provider)
const params: CompletionsParams = { const params: CompletionsParams = {
callType: 'search', callType: 'search',
messages: messages, messages: messages,
assistant, assistant,
streamOutput: false streamOutput: false,
topicId
} }
return await AI.completions(params) return await AI.completionsForTrace(params)
} }
export async function fetchGenerate({ export async function fetchGenerate({

View File

@ -1,8 +1,10 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { Span } from '@opentelemetry/api'
import AiProvider from '@renderer/aiCore' import AiProvider from '@renderer/aiCore'
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, DEFAULT_KNOWLEDGE_THRESHOLD } from '@renderer/config/constant' import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, DEFAULT_KNOWLEDGE_THRESHOLD } from '@renderer/config/constant'
import { getEmbeddingMaxContext } from '@renderer/config/embedings' import { getEmbeddingMaxContext } from '@renderer/config/embedings'
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import store from '@renderer/store' import store from '@renderer/store'
import { FileMetadata, KnowledgeBase, KnowledgeBaseParams, KnowledgeReference } from '@renderer/types' import { FileMetadata, KnowledgeBase, KnowledgeBaseParams, KnowledgeReference } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract' import { ExtractResults } from '@renderer/utils/extract'
@ -102,18 +104,40 @@ export const getKnowledgeSourceUrl = async (item: ExtractChunkData & { file: Fil
export const searchKnowledgeBase = async ( export const searchKnowledgeBase = async (
query: string, query: string,
base: KnowledgeBase, base: KnowledgeBase,
rewrite?: string rewrite?: string,
topicId?: string,
parentSpanId?: string,
modelName?: string
): Promise<Array<ExtractChunkData & { file: FileMetadata | null }>> => { ): Promise<Array<ExtractChunkData & { file: FileMetadata | null }>> => {
let currentSpan: Span | undefined = undefined
try { try {
const baseParams = getKnowledgeBaseParams(base) const baseParams = getKnowledgeBaseParams(base)
const documentCount = base.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT const documentCount = base.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT
const threshold = base.threshold || DEFAULT_KNOWLEDGE_THRESHOLD const threshold = base.threshold || DEFAULT_KNOWLEDGE_THRESHOLD
if (topicId) {
currentSpan = addSpan({
topicId,
name: `${base.name}-search`,
inputs: {
query,
rewrite,
base: baseParams
},
tag: 'Knowledge',
parentSpanId,
modelName
})
}
// 执行搜索 // 执行搜索
const searchResults = await window.api.knowledgeBase.search({ const searchResults = await window.api.knowledgeBase.search(
search: rewrite || query, {
base: baseParams search: rewrite || query,
}) base: baseParams
},
currentSpan?.spanContext()
)
// 过滤阈值不达标的结果 // 过滤阈值不达标的结果
const filteredResults = searchResults.filter((item) => item.score >= threshold) const filteredResults = searchResults.filter((item) => item.score >= threshold)
@ -121,33 +145,56 @@ export const searchKnowledgeBase = async (
// 如果有rerank模型执行重排 // 如果有rerank模型执行重排
let rerankResults = filteredResults let rerankResults = filteredResults
if (base.rerankModel && filteredResults.length > 0) { if (base.rerankModel && filteredResults.length > 0) {
rerankResults = await window.api.knowledgeBase.rerank({ rerankResults = await window.api.knowledgeBase.rerank(
search: rewrite || query, {
base: baseParams, search: rewrite || query,
results: filteredResults base: baseParams,
}) results: filteredResults
},
currentSpan?.spanContext()
)
} }
// 限制文档数量 // 限制文档数量
const limitedResults = rerankResults.slice(0, documentCount) const limitedResults = rerankResults.slice(0, documentCount)
// 处理文件信息 // 处理文件信息
return await Promise.all( const result = await Promise.all(
limitedResults.map(async (item) => { limitedResults.map(async (item) => {
const file = await getFileFromUrl(item.metadata.source) const file = await getFileFromUrl(item.metadata.source)
logger.debug('Knowledge search item:', item, 'File:', file) logger.debug('Knowledge search item:', item, 'File:', file)
return { ...item, file } return { ...item, file }
}) })
) )
if (topicId) {
endSpan({
topicId,
outputs: result,
span: currentSpan,
modelName
})
}
return result
} catch (error) { } catch (error) {
logger.error(`Error searching knowledge base ${base.name}:`, error) logger.error(`Error searching knowledge base ${base.name}:`, error)
if (topicId) {
endSpan({
topicId,
error: error instanceof Error ? error : new Error(String(error)),
span: currentSpan,
modelName
})
}
throw error throw error
} }
} }
export const processKnowledgeSearch = async ( export const processKnowledgeSearch = async (
extractResults: ExtractResults, extractResults: ExtractResults,
knowledgeBaseIds: string[] | undefined knowledgeBaseIds: string[] | undefined,
topicId: string,
parentSpanId?: string,
modelName?: string
): Promise<KnowledgeReference[]> => { ): Promise<KnowledgeReference[]> => {
if ( if (
!extractResults.knowledge?.question || !extractResults.knowledge?.question ||
@ -167,10 +214,27 @@ export const processKnowledgeSearch = async (
return [] return []
} }
const span = addSpan({
topicId,
name: 'knowledgeSearch',
inputs: {
questions,
rewrite,
knowledgeBaseIds: knowledgeBaseIds
},
tag: 'Knowledge',
parentSpanId,
modelName
})
// 为每个知识库执行多问题搜索 // 为每个知识库执行多问题搜索
const baseSearchPromises = bases.map(async (base) => { const baseSearchPromises = bases.map(async (base) => {
// 为每个问题搜索并合并结果 // 为每个问题搜索并合并结果
const allResults = await Promise.all(questions.map((question) => searchKnowledgeBase(question, base, rewrite))) const allResults = await Promise.all(
questions.map((question) =>
searchKnowledgeBase(question, base, rewrite, topicId, span?.spanContext().spanId, modelName)
)
)
// 合并结果并去重 // 合并结果并去重
const flatResults = allResults.flat() const flatResults = allResults.flat()
@ -179,7 +243,7 @@ export const processKnowledgeSearch = async (
).sort((a, b) => b.score - a.score) ).sort((a, b) => b.score - a.score)
// 转换为引用格式 // 转换为引用格式
return await Promise.all( const result = await Promise.all(
uniqueResults.map( uniqueResults.map(
async (item, index) => async (item, index) =>
({ ({
@ -190,12 +254,20 @@ export const processKnowledgeSearch = async (
}) as KnowledgeReference }) as KnowledgeReference
) )
) )
return result
}) })
// 汇总所有知识库的结果 // 汇总所有知识库的结果
const resultsPerBase = await Promise.all(baseSearchPromises) const resultsPerBase = await Promise.all(baseSearchPromises)
const allReferencesRaw = resultsPerBase.flat().filter((ref): ref is KnowledgeReference => !!ref) const allReferencesRaw = resultsPerBase.flat().filter((ref): ref is KnowledgeReference => !!ref)
endSpan({
topicId,
outputs: resultsPerBase,
span,
modelName
})
// 重新为引用分配ID // 重新为引用分配ID
return allReferencesRaw.map((ref, index) => ({ return allReferencesRaw.map((ref, index) => ({
...ref, ...ref,

View File

@ -0,0 +1,358 @@
import { MessageStream } from '@anthropic-ai/sdk/resources/messages/messages'
import { defaultConfig, SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { cleanContext, endContext, getContext, startContext } from '@mcp-trace/trace-web'
import { Context, context, Span, SpanStatusCode, trace } from '@opentelemetry/api'
import { isAsyncIterable } from '@renderer/aiCore/middleware/utils'
import { db } from '@renderer/databases'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { handleAsyncIterable } from '@renderer/trace/dataHandler/AsyncIterableHandler'
import { handleResult } from '@renderer/trace/dataHandler/CommonResultHandler'
import { handleMessageStream } from '@renderer/trace/dataHandler/MessageStreamHandler'
import { handleStream } from '@renderer/trace/dataHandler/StreamHandler'
import { EndSpanParams, ModelSpanEntity, StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import { Model, Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { MessageBlockType } from '@renderer/types/newMessage'
import { SdkRawChunk } from '@renderer/types/sdk'
import { Stream } from 'openai/streaming'
class SpanManagerService {
private spanMap: Map<string, ModelSpanEntity[]> = new Map()
getModelSpanEntity(topicId: string, modelName?: string) {
const entities = this.spanMap.get(topicId)
if (!entities) {
const entity = new ModelSpanEntity(modelName)
this.spanMap.set(topicId, [entity])
return entity
}
let entity = entities.find((e) => e.getModelName() === modelName)
if (!entity) {
entity = new ModelSpanEntity(modelName)
entities.push(entity)
}
return entity
}
startTrace(params: StartSpanParams, models?: Model[]) {
if (!defaultConfig.isDevModel) {
console.warn('Trace is enabled in developer mode.')
return
}
const span = webTracer.startSpan(params.name || 'root', {
root: true,
attributes: {
inputs: JSON.stringify(params.inputs || {}),
models: JSON.stringify(models || [])
}
})
const entity = this.getModelSpanEntity(params.topicId)
entity.addSpan(span)
const traceId = span.spanContext().traceId
window.api.trace.bindTopic(params.topicId, traceId)
const ctx = this._updateContext(span, params.topicId)
models?.forEach((model) => {
this._addModelRootSpan({ ...params, name: `${model.name}.handleMessage`, modelName: model.name }, ctx)
})
return span
}
async restartTrace(message: Message, text?: string) {
if (!defaultConfig.isDevModel) {
console.warn('Trace is enabled in developer mode.')
return
}
if (!message.traceId) {
return
}
await window.api.trace.bindTopic(message.topicId, message.traceId)
const input = await this._getContentFromMessage(message, text)
let _models
if (message.role === 'user') {
await window.api.trace.cleanHistory(message.topicId, message.traceId)
const topic = await db.topics.get(message.topicId)
_models = topic?.messages.filter((m) => m.role === 'assistant' && m.askId === message.id).map((m) => m.model)
} else {
_models = [message.model]
await window.api.trace.cleanHistory(message.topicId, message.traceId || '', message.model?.name)
}
_models
?.filter((m) => !!m)
.forEach((model) => {
this._addModelRootSpan({ ...input, modelName: model.name, name: `${model.name}.resendMessage` })
})
const modelName = message.role !== 'user' ? _models[0]?.name : undefined
window.api.trace.openWindow(message.topicId, message.traceId, false, modelName)
}
async appendTrace(message: Message, model: Model) {
if (!defaultConfig.isDevModel) {
console.warn('Trace is enabled in developer mode.')
return
}
if (!message.traceId) {
return
}
await window.api.trace.cleanHistory(message.topicId, message.traceId, model.name)
const input = await this._getContentFromMessage(message)
await window.api.trace.bindTopic(message.topicId, message.traceId)
this._addModelRootSpan({ ...input, name: `${model.name}.appendMessage`, modelName: model.name })
window.api.trace.openWindow(message.topicId, message.traceId, false, model.name)
}
private async _getContentFromMessage(message: Message, content?: string): Promise<StartSpanParams> {
let _content = content
if (!_content) {
const blocks = await Promise.all(
message.blocks.map(async (blockId) => {
return await db.message_blocks.get(blockId)
})
)
_content = blocks.find((data) => data?.type === MessageBlockType.MAIN_TEXT)?.content
}
return {
topicId: message.topicId,
inputs: {
messageId: message.id,
content: _content,
askId: message.askId,
traceId: message.traceId,
tag: 'resendMessage'
}
}
}
private _updateContext(span: Span, topicId: string, traceId?: string) {
window.api.trace.saveEntity({
id: span.spanContext().spanId,
traceId: traceId ? traceId : span.spanContext().traceId,
topicId
} as SpanEntity)
if (traceId) {
span['_spanContext'].traceId = traceId
}
const ctx = trace.setSpan(context.active(), span)
startContext(topicId, ctx)
return ctx
}
private _addModelRootSpan(params: StartSpanParams, ctx?: Context) {
const entity = this.getModelSpanEntity(params.topicId, params.modelName)
const rootSpan = webTracer.startSpan(
`${params.name}`,
{
attributes: {
inputs: JSON.stringify(params.inputs || {}),
modelName: params.modelName,
tags: 'ModelHandle'
}
},
ctx
)
entity.addSpan(rootSpan, true)
const traceId = params.inputs?.traceId || rootSpan.spanContext().traceId
return this._updateContext(rootSpan, params.topicId, traceId)
}
endTrace(params: EndSpanParams) {
const entity = this.getModelSpanEntity(params.topicId)
let span = entity.getCurrentSpan()
const code = params.error ? SpanStatusCode.ERROR : SpanStatusCode.OK
const message = params.error ? params.error.message : ''
while (span) {
if (params.outputs) {
span.setAttributes({ outputs: params.outputs })
}
if (params.error) {
span.recordException(params.error)
}
span.setStatus({ code, message })
span.end()
entity.removeSpan(span)
span = entity.getCurrentSpan()
}
this.finishModelTrace(params.topicId)
cleanContext(params.topicId)
window.api.trace.saveData(params.topicId)
}
addSpan(params: StartSpanParams) {
if (!defaultConfig.isDevModel) {
console.warn('Trace is enabled in developer mode.')
return
}
const entity = this.getModelSpanEntity(params.topicId, params.modelName)
let parentSpan = entity.getSpanById(params.parentSpanId)
if (!parentSpan) {
parentSpan = this.getCurrentSpan(params.topicId, params.modelName)
}
const parentCtx = parentSpan ? trace.setSpan(context.active(), parentSpan) : getContext(params.topicId)
const span = webTracer.startSpan(
params.name || 'root',
{
attributes: {
inputs: JSON.stringify(params.inputs || {}),
tags: params.tag || '',
modelName: params.modelName
}
},
parentCtx
)
const ctx = trace.setSpan(getContext(params.topicId), span)
entity.addSpan(span)
startContext(params.topicId, ctx)
return span
}
endSpan(params: EndSpanParams) {
const entity = this.getModelSpanEntity(params.topicId, params.modelName)
const span = params.span || entity.getCurrentSpan(params.modelName)
if (params.modelEnded && params.modelName && params.outputs) {
const rootEntity = this.getModelSpanEntity(params.topicId)
const span = rootEntity?.getRootSpan()
window.api.trace.addEndMessage(span?.spanContext().spanId || '', params.modelName, params.outputs)
}
if (params.modelEnded && params.error && params.modelName) {
const rootEntity = this.getModelSpanEntity(params.topicId)
rootEntity.addModelError(params.error)
}
if (!span) {
console.info(`No active span found for topicId: ${params.topicId}-modelName: ${params.modelName}.`)
return
}
// remove span
if (entity.removeSpan(span)) {
this.getModelSpanEntity(params.topicId).removeSpan(span)
}
const code = params.error ? SpanStatusCode.ERROR : SpanStatusCode.OK
const message = params.error ? params.error.message : 'success'
if (params.outputs) {
span.setAttributes({ outputs: JSON.stringify(params.outputs || {}) })
}
if (params.error) {
span.recordException(params.error)
}
span.setStatus({ code, message })
span.end()
endContext(params.topicId)
}
getCurrentSpan(topicId: string, modelName?: string, isRoot = false): Span | undefined {
let entity = this.getModelSpanEntity(topicId, modelName)
let span = isRoot ? entity.getRoot() : entity.getCurrentSpan(modelName)
if (!span && modelName) {
entity = this.getModelSpanEntity(topicId)
span = entity.getCurrentSpan()
}
return span
}
async addTokenUsage(topicId: string, prompt: number, completion: number) {
const span = this.getCurrentSpan(topicId)
const usage: TokenUsage = {
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: prompt + completion
}
if (span) {
window.api.trace.tokenUsage(span.spanContext().spanId, usage)
}
}
async finishModelTrace(topicId: string) {
this.spanMap.get(topicId)?.forEach((entity) => entity.finishSpan())
this.spanMap.delete(topicId)
}
}
/**
* Wraps a function and executes it within a span, returning the function's result instead of the wrapped function.
* @param fn The function to execute.
* @param name The span name.
* @param tags The span tags.
* @param getTopicId Function to get topicId from arguments.
* @returns The result of the executed function.
*/
export function withSpanResult<F extends (...args: any) => any>(
fn: F,
params: StartSpanParams,
...args: Parameters<F>
): ReturnType<F> {
if (!params.topicId || params.topicId === '') {
return fn(...args)
}
const span = addSpan({
topicId: params.topicId,
name: params.name,
tag: params.tag,
inputs: args,
parentSpanId: params.parentSpanId,
modelName: params.modelName
})
try {
const result = fn(...args)
if (result instanceof Promise) {
return result
.then((data) => {
if (!data || typeof data !== 'object') {
endSpan({ topicId: params.topicId, outputs: data, span, modelName: params.modelName })
return data
}
if (data instanceof Stream) {
return handleStream(data, span, params.topicId, params.modelName)
} else if (data instanceof MessageStream) {
return handleMessageStream(data, span, params.topicId, params.modelName)
} else if (isAsyncIterable<SdkRawChunk>(data)) {
return handleAsyncIterable(data, span, params.topicId, params.modelName)
} else {
return handleResult(data, span, params.topicId, params.modelName)
}
})
.catch((err) => {
endSpan({ topicId: params.topicId, error: err, span, modelName: params.modelName })
throw err
}) as ReturnType<F>
} else {
endSpan({ topicId: params.topicId, outputs: result, span, modelName: params.modelName })
return result
}
} catch (err) {
endSpan({ topicId: params.topicId, error: err as Error, span, modelName: params.modelName })
throw err
}
}
export const spanManagerService = new SpanManagerService()
export const webTracer = trace.getTracer('CherryStudio', '1.0.0')
export const addSpan = spanManagerService.addSpan.bind(spanManagerService)
export const startTrace = spanManagerService.startTrace.bind(spanManagerService)
export const endTrace = spanManagerService.endTrace.bind(spanManagerService)
export const endSpan = spanManagerService.endSpan.bind(spanManagerService)
export const currentSpan = spanManagerService.getCurrentSpan.bind(spanManagerService)
export const addTokenUsage = spanManagerService.addTokenUsage.bind(spanManagerService)
export const pauseTrace = spanManagerService.finishModelTrace.bind(spanManagerService)
export const appendTrace = spanManagerService.appendTrace.bind(spanManagerService)
export const restartTrace = spanManagerService.restartTrace.bind(spanManagerService)
EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, ({ topicId, traceId }) => {
window.api.trace.openWindow(topicId, traceId, false)
})
EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, (topic: Topic) => {
window.api.trace.cleanTopic(topic.id)
})

View File

@ -2,6 +2,7 @@ import { loggerService } from '@logger'
import { DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT } from '@renderer/config/constant' import { DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT } from '@renderer/config/constant'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import WebSearchEngineProvider from '@renderer/providers/WebSearchProvider' import WebSearchEngineProvider from '@renderer/providers/WebSearchProvider'
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import store from '@renderer/store' import store from '@renderer/store'
import { setWebSearchStatus } from '@renderer/store/runtime' import { setWebSearchStatus } from '@renderer/store/runtime'
import { CompressionConfig, WebSearchState } from '@renderer/store/websearch' import { CompressionConfig, WebSearchState } from '@renderer/store/websearch'
@ -164,10 +165,11 @@ class WebSearchService {
public async search( public async search(
provider: WebSearchProvider, provider: WebSearchProvider,
query: string, query: string,
httpOptions?: RequestInit httpOptions?: RequestInit,
spanId?: string
): Promise<WebSearchProviderResponse> { ): Promise<WebSearchProviderResponse> {
const websearch = this.getWebSearchState() const websearch = this.getWebSearchState()
const webSearchEngine = new WebSearchEngineProvider(provider) const webSearchEngine = new WebSearchEngineProvider(provider, spanId)
let formattedQuery = query let formattedQuery = query
// FIXME: 有待商榷,效果一般 // FIXME: 有待商榷,效果一般
@ -440,16 +442,38 @@ class WebSearchService {
// 使用请求特定的signal如果没有则回退到全局signal // 使用请求特定的signal如果没有则回退到全局signal
const signal = this.getRequestState(requestId).signal || this.signal const signal = this.getRequestState(requestId).signal || this.signal
const span = webSearchProvider.topicId
? addSpan({
topicId: webSearchProvider.topicId,
name: `WebSearch`,
inputs: {
question: extractResults.websearch.question,
provider: webSearchProvider.id
},
tag: `Web`,
parentSpanId: webSearchProvider.parentSpanId,
modelName: webSearchProvider.modelName
})
: undefined
const questions = extractResults.websearch.question const questions = extractResults.websearch.question
const links = extractResults.websearch.links const links = extractResults.websearch.links
// 处理 summarize // 处理 summarize
if (questions[0] === 'summarize' && links && links.length > 0) { if (questions[0] === 'summarize' && links && links.length > 0) {
const contents = await fetchWebContents(links, undefined, undefined, { signal }) const contents = await fetchWebContents(links, undefined, undefined, { signal })
webSearchProvider.topicId &&
endSpan({
topicId: webSearchProvider.topicId,
outputs: contents,
modelName: webSearchProvider.modelName,
span
})
return { query: 'summaries', results: contents } return { query: 'summaries', results: contents }
} }
const searchPromises = questions.map((q) => this.search(webSearchProvider, q, { signal })) const searchPromises = questions.map((q) =>
this.search(webSearchProvider, q, { signal }, span?.spanContext().spanId)
)
const searchResults = await Promise.allSettled(searchPromises) const searchResults = await Promise.allSettled(searchPromises)
// 统计成功完成的搜索数量 // 统计成功完成的搜索数量
@ -480,6 +504,14 @@ class WebSearchService {
// 如果没有搜索结果,直接返回空结果 // 如果没有搜索结果,直接返回空结果
if (finalResults.length === 0) { if (finalResults.length === 0) {
await this.setWebSearchStatus(requestId, { phase: 'default' }) await this.setWebSearchStatus(requestId, { phase: 'default' })
if (webSearchProvider.topicId) {
endSpan({
topicId: webSearchProvider.topicId,
outputs: finalResults,
modelName: webSearchProvider.modelName,
span
})
}
return { return {
query: questions.join(' | '), query: questions.join(' | '),
results: [] results: []
@ -526,6 +558,14 @@ class WebSearchService {
// 重置状态 // 重置状态
await this.setWebSearchStatus(requestId, { phase: 'default' }) await this.setWebSearchStatus(requestId, { phase: 'default' })
if (webSearchProvider.topicId) {
endSpan({
topicId: webSearchProvider.topicId,
outputs: finalResults,
modelName: webSearchProvider.modelName,
span
})
}
return { return {
query: questions.join(' | '), query: questions.join(' | '),
results: finalResults results: finalResults

View File

@ -0,0 +1,34 @@
import { convertSpanToSpanEntity, FunctionSpanExporter, FunctionSpanProcessor } from '@mcp-trace/trace-core'
import { WebTracer } from '@mcp-trace/trace-web'
import { ReadableSpan } from '@opentelemetry/sdk-trace-base'
const TRACER_NAME = 'CherryStudio'
class WebTraceService {
init() {
const exporter = new FunctionSpanExporter((spans: ReadableSpan[]): Promise<void> => {
// Implement your save logic here if needed
// For now, just resolve immediately
console.log('Saving spans:', spans)
return Promise.resolve()
})
const processor = new FunctionSpanProcessor(
exporter,
(span: ReadableSpan) => {
window.api.trace.saveEntity(convertSpanToSpanEntity(span))
},
(span: ReadableSpan) => {
window.api.trace.saveEntity(convertSpanToSpanEntity(span))
}
)
WebTracer.init(
{
defaultTracerName: TRACER_NAME,
serviceName: TRACER_NAME
},
processor
)
}
}
export const webTraceService = new WebTraceService()

View File

@ -4,6 +4,7 @@ import { fetchChatCompletion } from '@renderer/services/ApiService'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { BlockManager } from '@renderer/services/messageStreaming/BlockManager' import { BlockManager } from '@renderer/services/messageStreaming/BlockManager'
import { createCallbacks } from '@renderer/services/messageStreaming/callbacks' import { createCallbacks } from '@renderer/services/messageStreaming/callbacks'
import { endSpan } from '@renderer/services/SpanManagerService'
import { createStreamProcessor, type StreamProcessorCallbacks } from '@renderer/services/StreamProcessingService' import { createStreamProcessor, type StreamProcessorCallbacks } from '@renderer/services/StreamProcessingService'
import store from '@renderer/store' import store from '@renderer/store'
import { updateTopicUpdatedAt } from '@renderer/store/assistants' import { updateTopicUpdatedAt } from '@renderer/store/assistants'
@ -258,7 +259,8 @@ const dispatchMultiModelResponses = async (
const assistantMessage = createAssistantMessage(assistant.id, topicId, { const assistantMessage = createAssistantMessage(assistant.id, topicId, {
askId: triggeringMessage.id, askId: triggeringMessage.id,
model: mentionedModel, model: mentionedModel,
modelId: mentionedModel.id modelId: mentionedModel.id,
traceId: triggeringMessage.traceId
}) })
dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
assistantMessageStubs.push(assistantMessage) assistantMessageStubs.push(assistantMessage)
@ -886,13 +888,24 @@ const fetchAndProcessAssistantResponseImpl = async (
const streamProcessorCallbacks = createStreamProcessor(callbacks) const streamProcessorCallbacks = createStreamProcessor(callbacks)
// const startTime = Date.now() // const startTime = Date.now()
await fetchChatCompletion({ const result = await fetchChatCompletion({
messages: messagesForContext, messages: messagesForContext,
assistant: assistant, assistant: assistant,
onChunkReceived: streamProcessorCallbacks onChunkReceived: streamProcessorCallbacks
}) })
endSpan({
topicId,
outputs: result ? result.getText() : '',
modelName: assistant.model?.name,
modelEnded: true
})
} catch (error: any) { } catch (error: any) {
logger.error('Error fetching chat completion:', error) logger.error('Error fetching chat completion:', error)
endSpan({
topicId,
error: error,
modelName: assistant.model?.name
})
if (assistantMessage) { if (assistantMessage) {
callbacks.onError?.(error) callbacks.onError?.(error)
throw error throw error
@ -930,7 +943,8 @@ export const sendMessage =
} else { } else {
const assistantMessage = createAssistantMessage(assistant.id, topicId, { const assistantMessage = createAssistantMessage(assistant.id, topicId, {
askId: userMessage.id, askId: userMessage.id,
model: assistant.model model: assistant.model,
traceId: userMessage.traceId
}) })
await saveMessageAndBlocksToDB(assistantMessage, []) await saveMessageAndBlocksToDB(assistantMessage, [])
dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
@ -1129,6 +1143,7 @@ export const resendMessageThunk =
askId: userMessageToResend.id, askId: userMessageToResend.id,
model: assistant.model model: assistant.model
}) })
assistantMessage.traceId = userMessageToResend.traceId
resetDataList.push(assistantMessage) resetDataList.push(assistantMessage)
resetDataList.forEach((message) => { resetDataList.forEach((message) => {
@ -1427,7 +1442,8 @@ export const appendAssistantResponseThunk =
topicId: Topic['id'], topicId: Topic['id'],
existingAssistantMessageId: string, // ID of the assistant message the user interacted with existingAssistantMessageId: string, // ID of the assistant message the user interacted with
newModel: Model, // The new model selected by the user newModel: Model, // The new model selected by the user
assistant: Assistant // Base assistant configuration assistant: Assistant, // Base assistant configuration
traceId?: string
) => ) =>
async (dispatch: AppDispatch, getState: () => RootState) => { async (dispatch: AppDispatch, getState: () => RootState) => {
try { try {
@ -1474,7 +1490,8 @@ export const appendAssistantResponseThunk =
const newAssistantStub = createAssistantMessage(assistant.id, topicId, { const newAssistantStub = createAssistantMessage(assistant.id, topicId, {
askId: askId, // Crucial: Use the original askId askId: askId, // Crucial: Use the original askId
model: newModel, model: newModel,
modelId: newModel.id modelId: newModel.id,
traceId: traceId
}) })
// 3. Update Redux Store // 3. Update Redux Store

View File

@ -0,0 +1,98 @@
import { TokenUsage } from '@mcp-trace/trace-core'
import { Span } from '@opentelemetry/api'
import { endSpan } from '@renderer/services/SpanManagerService'
import { SdkRawChunk } from '@renderer/types/sdk'
export class AsyncIterableHandler {
private span: Span
private stream: AsyncIterable<SdkRawChunk>
private topicId: string
private usageToken: TokenUsage
private modelName?: string
constructor(stream: AsyncIterable<SdkRawChunk>, span: Span, topicId: string, modelName?: string) {
this.stream = this.transformStream(stream)
this.span = span
this.topicId = topicId
this.modelName = modelName
this.usageToken = {
completion_tokens: 0,
prompt_tokens: 0,
total_tokens: 0
}
}
async handleChunk(chunk: SdkRawChunk) {
let context = 'choices' in chunk ? chunk.choices.map((ch) => ch.delta.context).join() : ''
if (!context && 'candidates' in chunk && chunk.candidates) {
context = chunk.candidates
.map(
(ch) =>
ch.content?.parts
?.map((p) => {
if (p.text) {
return p.text
} else if (p.functionCall) {
return `${p.functionCall.name}(${JSON.stringify(p.functionCall.args || '')})`
} else if (p.codeExecutionResult) {
return p.codeExecutionResult.output || String(p.codeExecutionResult.outcome || '')
} else if (p.executableCode) {
return `'''${p.executableCode.language || ''}\n${p.executableCode.code}\n'''`
} else if (p.fileData) {
return '<Blob Data>'
} else if (p.functionResponse) {
return `${p.functionResponse.name}: ${JSON.stringify(p.functionResponse.response)}`
} else if (p.inlineData) {
return '<File Data>'
} else if (p.videoMetadata) {
return `fps: ${p.videoMetadata.fps}, start:${p.videoMetadata.startOffset}, end:${p.videoMetadata.endOffset}`
} else {
return ''
}
})
.join() || ''
)
.join()
}
if (context) {
window.api.trace.addStreamMessage(this.span.spanContext().spanId, this.modelName || '', context, chunk)
}
if ('usageMetadata' in chunk && chunk.usageMetadata) {
this.usageToken.prompt_tokens = chunk.usageMetadata.promptTokenCount || 0
this.usageToken.total_tokens = chunk.usageMetadata.totalTokenCount || 0
this.usageToken.completion_tokens =
(chunk.usageMetadata.totalTokenCount || 0) - (chunk.usageMetadata.promptTokenCount || 0)
}
}
async finish() {
window.api.trace.tokenUsage(this.span.spanContext().spanId, this.usageToken)
endSpan({ topicId: this.topicId, span: this.span, modelName: this.modelName })
}
async handleError(err) {
endSpan({ topicId: this.topicId, error: err, span: this.span, modelName: this.modelName })
}
async *transformStream(stream: AsyncIterable<SdkRawChunk>) {
try {
for await (const chunk of stream) {
this.handleChunk(chunk)
yield chunk
}
} catch (err) {
this.handleError(err)
throw err
}
this.finish()
}
static handleStream(stream: AsyncIterable<SdkRawChunk>, span?: Span, topicId?: string, modelName?: string) {
if (!span || !topicId) {
return stream
}
const handler = new AsyncIterableHandler(stream, span!, topicId, modelName)
return handler.stream
}
}
export const handleAsyncIterable = AsyncIterableHandler.handleStream

View File

@ -0,0 +1,77 @@
import { TokenUsage } from '@mcp-trace/trace-core'
import { Span } from '@opentelemetry/api'
import { CompletionsResult } from '@renderer/aiCore/middleware/schemas'
import { endSpan } from '@renderer/services/SpanManagerService'
export class CompletionsResultHandler {
private data: any
private tokenUsage?: TokenUsage
private span: Span
private topicId: string
private modelName?: string
constructor(data: any, span: Span, topicId: string, modelName?: string) {
this.data = data && this.isCompletionsResult(data) ? { ...data, finishText: data.getText() } : data
this.span = span
this.topicId = topicId
this.tokenUsage = this.getUsage(data)
this.modelName = modelName
}
isCompletionsResult(data: any): data is CompletionsResult {
return (
data !== null &&
typeof data === 'object' &&
typeof data.getText === 'function' &&
(data.rawOutput === undefined || typeof data.rawOutput === 'object') &&
(data.stream === undefined || typeof data.stream === 'object') &&
(data.controller === undefined || data.controller instanceof AbortController)
)
}
getUsage(data?: any): TokenUsage | undefined {
// Replace this with an appropriate property check for CompletionsResult
if (!data || typeof data !== 'object' || !('usage' in data || 'usageMetadata' in data)) {
return undefined
}
const tokens: TokenUsage = {
completion_tokens: 0,
prompt_tokens: 0,
total_tokens: 0
}
if ('usage' in data) {
const usage = data.usage
tokens.completion_tokens = usage['completion_tokens'] || 0
tokens.prompt_tokens = usage['prompt_tokens'] || 0
tokens.total_tokens = usage['total_tokens'] || 0
// Do something with usage
} else {
const usage = data.usageMetadata
tokens.completion_tokens = usage['thoughtsTokenCount'] || 0
tokens.prompt_tokens = usage['promptTokenCount'] || 0
tokens.total_tokens = usage['totalTokenCount'] || 0
}
return tokens
}
finish() {
if (this.tokenUsage) {
window.api.trace.tokenUsage(this.span.spanContext().spanId, this.tokenUsage)
}
if (this.data) {
endSpan({ topicId: this.topicId, outputs: this.data, span: this.span, modelName: this.modelName })
} else {
endSpan({ topicId: this.topicId, span: this.span, modelName: this.modelName })
}
}
static handleResult(data?: any, span?: Span, topicId?: string, modelName?: string) {
if (span && topicId) {
const handler = new CompletionsResultHandler(data, span!, topicId, modelName)
handler.finish()
}
return data
}
}
export const handleResult = CompletionsResultHandler.handleResult

View File

@ -0,0 +1,70 @@
import { Message, MessageStream } from '@anthropic-ai/sdk/resources/messages/messages'
import { TokenUsage } from '@mcp-trace/trace-core'
import { Span } from '@opentelemetry/api'
import { endSpan } from '@renderer/services/SpanManagerService'
export class MessageStreamHandler {
private span: Span
private stream: MessageStream
private topicId: string
private tokenUsage: TokenUsage
private modelName?: string
constructor(stream: MessageStream, span: Span, topicId: string, modelName?: string) {
this.stream = stream
this.span = span
this.topicId = topicId
this.tokenUsage = {
completion_tokens: 0,
prompt_tokens: 0,
total_tokens: 0
}
stream.on('error', (err) => {
endSpan({ topicId, error: err, span, modelName: this.modelName })
})
stream.on('message', (message) => this.write(message))
stream.on('end', () => this.finish())
this.modelName = modelName
}
async finish() {
window.api.trace.tokenUsage(this.span.spanContext().spanId, this.tokenUsage)
endSpan({ topicId: this.topicId, span: this.span, modelName: this.modelName })
}
async write(message: Message) {
if (message.usage) {
this.tokenUsage.completion_tokens += message.usage.output_tokens
this.tokenUsage.prompt_tokens += message.usage.input_tokens
this.tokenUsage.total_tokens += message.usage.output_tokens + message.usage.input_tokens
}
const context = message.content
.map((c) => {
if (c.type === 'text') {
return c.text
} else if (c.type === 'redacted_thinking') {
return c.data
} else if (c.type === 'server_tool_use' || c.type === 'tool_use') {
return `${c.name}: ${c.input}`
} else if (c.type === 'thinking') {
return c.thinking
} else if (c.type === 'web_search_tool_result') {
return c.content
} else {
return JSON.stringify(c)
}
})
.join()
window.api.trace.addStreamMessage(this.span.spanContext().spanId, this.modelName || '', context, message)
}
static handleStream(stream: MessageStream, span?: Span, topicId?: string, modelName?: string) {
if (!span || !topicId) {
return stream
}
const handler = new MessageStreamHandler(stream, span!, topicId, modelName)
return handler.stream
}
}
export const handleMessageStream = MessageStreamHandler.handleStream

View File

@ -0,0 +1,110 @@
import { TokenUsage } from '@mcp-trace/trace-core'
import { Span } from '@opentelemetry/api'
import { endSpan } from '@renderer/services/SpanManagerService'
import { OpenAI } from 'openai'
import { Stream } from 'openai/streaming'
export class StreamHandler {
private topicId: string
private span: Span
private modelName?: string
private usage: TokenUsage = {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
private stream: Stream<OpenAI.Chat.Completions.ChatCompletionChunk | OpenAI.Responses.ResponseStreamEvent>
constructor(
topicId: string,
span: Span,
stream: Stream<OpenAI.Chat.Completions.ChatCompletionChunk | OpenAI.Responses.ResponseStreamEvent>,
modelName?: string
) {
this.topicId = topicId
this.span = span
this.modelName = modelName
this.stream = stream
}
async *createStreamAdapter(): AsyncIterable<
OpenAI.Chat.Completions.ChatCompletionChunk | OpenAI.Responses.ResponseStreamEvent
> {
try {
for await (const chunk of this.stream) {
let context: string | undefined
if ('object' in chunk && chunk.object === 'chat.completion.chunk') {
const completionChunk = chunk as OpenAI.Chat.Completions.ChatCompletionChunk
if (completionChunk.usage) {
this.usage.completion_tokens += completionChunk.usage.completion_tokens || 0
this.usage.prompt_tokens += completionChunk.usage.prompt_tokens || 0
this.usage.total_tokens += completionChunk.usage.total_tokens || 0
}
context = chunk.choices
.map((choice) => {
if (!choice.delta) {
return ''
} else if ('reasoning_content' in choice.delta) {
return choice.delta.reasoning_content
} else if (choice.delta.content) {
return choice.delta.content
} else if (choice.delta.refusal) {
return choice.delta.refusal
} else if (choice.delta.tool_calls) {
return choice.delta.tool_calls.map((toolCall) => {
return toolCall.function?.name || toolCall.function?.arguments
})
}
return ''
})
.join()
} else {
const resp = chunk as OpenAI.Responses.ResponseStreamEvent
if ('response' in resp && resp.response) {
context = resp.response.output_text
if (resp.response.usage) {
this.usage.completion_tokens += resp.response.usage.output_tokens || 0
this.usage.prompt_tokens += resp.response.usage.input_tokens || 0
this.usage.total_tokens += (resp.response.usage.input_tokens || 0) + resp.response.usage.output_tokens
}
} else if ('delta' in resp && resp.delta) {
context = typeof resp.delta === 'string' ? resp.delta : JSON.stringify(resp.delta)
} else if ('text' in resp && resp.text) {
context = resp.text
} else if ('partial_image_b64' in resp && resp.partial_image_b64) {
context = '<Image Data>'
} else if ('part' in resp && resp.part) {
context = 'refusal' in resp.part ? resp.part.refusal : resp.part.text
} else {
context = ''
}
}
window.api.trace.addStreamMessage(this.span.spanContext().spanId, this.modelName || '', context, chunk)
yield chunk
}
this.finish()
} catch (err) {
endSpan({ topicId: this.topicId, error: err as Error, span: this.span, modelName: this.modelName })
throw err
}
}
async finish() {
window.api.trace.tokenUsage(this.span.spanContext().spanId, this.usage)
endSpan({ topicId: this.topicId, span: this.span, modelName: this.modelName })
}
static handleStream(
stream: Stream<OpenAI.Chat.Completions.ChatCompletionChunk | OpenAI.Responses.ResponseStreamEvent>,
span?: Span,
topicId?: string,
modelName?: string
) {
if (!span || !topicId) {
return stream
}
return new StreamHandler(topicId, span, stream, modelName).createStreamAdapter()
}
}
export const handleStream = StreamHandler.handleStream

View File

@ -0,0 +1,164 @@
import { CaretDownOutlined, CaretRightOutlined } from '@ant-design/icons'
import React from 'react'
// Box 组件
export const Box: React.FC<
React.HTMLAttributes<HTMLDivElement> & { padding?: number; border?: string; borderStyle?: string; className?: string }
> = ({ padding: p, border, borderStyle, className, style, ...props }) => (
<div
className={className}
style={{
padding: p ? `${p}px` : undefined,
border: border,
borderStyle: borderStyle,
...style
}}
{...props}
/>
)
// SimpleGrid 组件
export const SimpleGrid: React.FC<{
columns?: number
templateColumns?: string
children: React.ReactNode
leftSpace?: number
className?: string
style?: React.CSSProperties
onClick?: React.MouseEventHandler<HTMLDivElement>
}> = ({ columns, templateColumns, children, leftSpace = 0, style, className, onClick, ...props }) => (
<div
className={className}
style={{
display: 'grid',
gridTemplateColumns: templateColumns || (columns ? `repeat(${columns}, 1fr)` : undefined),
gap: '1px',
paddingLeft: leftSpace,
...style
}}
onClick={onClick}
{...props}>
{children}
</div>
)
// Text 组件
export const Text: React.FC<React.HTMLAttributes<HTMLSpanElement>> = ({ style, className, ...props }) => (
<span
style={{ fontSize: 12, ...style, cursor: props.onClick ? 'pointer' : undefined }}
className={className}
{...props}
onClick={props.onClick ? props.onClick : undefined}
/>
)
// VStack 组件
export const VStack: React.FC<{ grap?: number; align?: string; children: React.ReactNode }> = ({
grap = 5,
align = 'stretch',
children,
...props
}) => (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: align,
gap: `${grap}px`
}}
{...props}>
{children}
</div>
)
// GridItem 组件
export const GridItem: React.FC<
React.HTMLAttributes<HTMLDivElement> & { colSpan?: number; rowSpan?: number; padding?: number }
> = ({ colSpan, rowSpan, padding, style, ...props }) => (
<div
style={{
gridColumn: colSpan ? `span ${colSpan}` : undefined,
gridRow: rowSpan ? `span ${rowSpan}` : undefined,
padding: padding ? `${padding}px` : undefined,
textAlign: 'center',
...style
}}
{...props}
/>
)
// HStack 组件
export const HStack: React.FC<{ grap?: number; children: React.ReactNode; style?: React.CSSProperties }> = ({
grap,
children,
style,
...props
}) => (
<div
style={{
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'center',
gap: grap ? `${grap}px` : '5px',
...style
}}
{...props}>
{children}
</div>
)
// IconButton 组件
export const IconButton: React.FC<
React.ButtonHTMLAttributes<HTMLButtonElement> & { size?: 'sm' | 'md'; fontSize?: string }
> = ({ size = 'md', fontSize = '12px', style, onClick, ...props }) => (
<button
type="button"
onClick={onClick}
style={{
width: size === 'sm' ? 12 : 20,
height: 24,
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...style
}}
{...props}>
{props.children ||
(props['aria-label'] === 'Toggle' ? props['aria-expanded'] ? <CaretDownOutlined /> : <CaretRightOutlined /> : '')}
</button>
)
// 自定义 Button 组件
export const Button: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>> = ({ style, ...props }) => (
<button
type="button"
style={{
padding: '5px 10px',
border: 'none',
cursor: 'pointer',
...style
}}
{...props}
/>
)
export const TraceIcon = ({ size = 200, color = 'currentColor', className = 'icon' }) => {
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg">
<path
d="M919.296 515.072a93.3376 93.3376 0 0 0-31.6928 5.8368l-142.6944-214.1184a108.5952 108.5952 0 0 0 17.3056-58.7264 109.9776 109.9776 0 1 0-192.256 72.192l-143.8208 263.7312a151.5008 151.5008 0 0 0-40.96-6.0928 155.8528 155.8528 0 0 0-84.6848 25.1904l-115.2-138.24a93.2352 93.2352 0 0 0 11.4176-44.032 94.2592 94.2592 0 1 0-57.6 87.04l116.0704 139.264a157.3376 157.3376 0 1 0 226.9184-34.56l141.1072-258.7136a104.0384 104.0384 0 0 0 73.728-5.12l141.7728 212.6336a94.0032 94.0032 0 1 0 80.4864-46.08zM385.28 829.44a94.2592 94.2592 0 1 1 94.208-94.2592A94.3616 94.3616 0 0 1 385.28 829.44z m0 0"
fill={color}
/>
</svg>
)
}

View File

@ -0,0 +1,33 @@
import React from 'react'
export interface ProgressBarProps {
start: number
progress: number
height?: number
}
export const ProgressBar: React.FC<ProgressBarProps> = ({ start = 0, progress, height = 6 }) => {
const displayProgress = Math.max(0, progress)
return (
<div
style={{
width: '100%',
backgroundColor: '#e0e0e0',
borderRadius: height,
overflow: 'hidden',
marginTop: '8px'
}}>
<div
style={{
width: `${displayProgress}%`,
height: height,
backgroundColor: '#4CAF50',
borderRadius: height,
transition: 'width 0.3s ease',
marginLeft: `${start}%`
}}
/>
</div>
)
}

View File

@ -0,0 +1,171 @@
import './Trace.css'
import { DoubleLeftOutlined } from '@ant-design/icons'
// import TraceModal from '@renderer/trace/TraceModal'
import { TraceModal } from '@renderer/trace/pages/TraceModel'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReactJson from 'react-json-view'
import { Box, Button, Text } from './Component'
import { convertTime } from './TraceTree'
interface SpanDetailProps {
node: TraceModal
clickShowModal: (input: boolean) => void
}
const SpanDetail: FC<SpanDetailProps> = ({ node, clickShowModal }) => {
const [showInput, setShowInput] = useState(true)
const [jsonData, setJsonData] = useState<object>({})
const [isJson, setIsJson] = useState(false)
const [usedTime, setUsedTime] = useState<string>('')
const { t } = useTranslation()
const changeJsonData = useCallback(() => {
let data: any = {}
if (!node.attributes) {
setJsonData(data)
setIsJson(true)
return
}
data = showInput ? node.attributes.inputs : node.attributes.outputs
if (!showInput && node.status === 'ERROR') {
data = node.events && Array.isArray(node.events) ? node.events?.find((e) => e.name === 'exception') : undefined
}
if (typeof data === 'string' && (data.startsWith('{') || data.startsWith('['))) {
try {
setJsonData(JSON.parse(data))
setIsJson(true)
return
} catch {
console.error('failed to parse json data:', data)
}
} else if (typeof data === 'object' || Array.isArray(data)) {
setJsonData(data)
setIsJson(true)
return
}
setIsJson(false)
setJsonData(data as unknown as object)
}, [node.attributes, node.status, node.events, showInput])
useEffect(() => {
setUsedTime(convertTime((node.endTime || Date.now()) - node.startTime))
changeJsonData()
}, [node.endTime, node.startTime, node.attributes, node.events, changeJsonData])
useEffect(() => {
const updateCopyButtonTitles = () => {
const copyButtons = document.querySelectorAll('.copy-to-clipboard-container > span')
copyButtons.forEach((btn) => {
btn.setAttribute('title', t('code_block.copy'))
})
}
updateCopyButtonTitles()
const timer = setInterval(updateCopyButtonTitles, 100) // 每秒检查一次
return () => clearInterval(timer)
}, [t])
const formatDate = (timestamp: number | null) => {
if (timestamp == null) {
return ''
}
const date = new Date(timestamp)
const pad = (n: number) => n.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds())}`
}
return (
<Box padding={5}>
<Box padding={0} style={{ marginBottom: 10 }}>
<a
onClick={(e) => {
e.preventDefault()
clickShowModal(true)
}}
href={'#'}
style={{ marginRight: 8, fontSize: '14px' }}>
<DoubleLeftOutlined style={{ fontSize: '12px' }} />
&nbsp;{t('trace.backList')}
</a>
</Box>
<Text style={{ fontWeight: 'bold', fontSize: 14 }}>{t('trace.spanDetail')}</Text>
<Box padding={0}>
<Text style={{ fontWeight: 'bold' }}>ID: </Text>
<Text>{node.id}</Text>
</Box>
<Box padding={0}>
<Text style={{ fontWeight: 'bold' }}>{t('trace.name')}: </Text>
<Text>{node.name}</Text>
</Box>
<Box padding={0}>
<Text style={{ fontWeight: 'bold' }}>{t('trace.tag')}: </Text>
<Text>{String(node.attributes?.tags || '')}</Text>
</Box>
<Box padding={0}>
<Text style={{ fontWeight: 'bold' }}>{t('trace.startTime')}: </Text>
<Text>{formatDate(node.startTime)}</Text>
</Box>
<Box padding={0}>
<Text style={{ fontWeight: 'bold' }}>{t('trace.endTime')}: </Text>
<Text>{formatDate(node.endTime)}</Text>
</Box>
{node.usage && (
<Box padding={0}>
<Text style={{ fontWeight: 'bold' }}>{t('trace.tokenUsage')}: </Text>
<Text style={{ color: 'red' }}>{`${node.usage.prompt_tokens}`}</Text>&nbsp;
<Text style={{ color: 'green' }}>{`${node.usage.completion_tokens}`}</Text>
</Box>
)}
<Box padding={0}>
<Text style={{ fontWeight: 'bold' }}>{t('trace.spendTime')}: </Text>
<Text>{usedTime}</Text>
</Box>
{/* <Box padding={0}>
<Text style={{ fontWeight: 'bold' }}>{t('trace.parentId')}: </Text>
<Text>{node.parentId}</Text>
</Box> */}
<Box style={{ position: 'relative', margin: '5px 0 0' }}>
<Button className={`content-button ${showInput ? 'active' : ''}`} onClick={() => setShowInput(true)}>
{t('trace.inputs')}
</Button>
<Button className={`content-button ${showInput ? '' : 'active'}`} onClick={() => setShowInput(false)}>
{t('trace.outputs')}
</Button>
</Box>
<Box className="code-container">
{isJson ? (
<ReactJson
src={jsonData || ''}
displayDataTypes={false}
displayObjectSize={false}
indentWidth={2}
collapseStringsAfterLength={100}
name={false}
theme={'colors'}
style={{ fontSize: '12px' }}
/>
) : (
<pre
style={{
color: 'white',
background: '#181c20',
padding: '12px',
borderRadius: 0,
fontSize: 12,
overflowX: 'auto',
marginTop: '2px'
}}>
<code className="code-context">{`${typeof jsonData === 'object' ? JSON.stringify(jsonData, null, 2) : String(jsonData)}`}</code>
</pre>
)}
</Box>
</Box>
)
}
export default SpanDetail

View File

@ -0,0 +1,234 @@
:root {
--trace-bg: #fff;
--trace-header-bg: #e5e5e5;
--trace-border: #eee;
--trace-text: #222;
--trace-header: #eeeeee;
}
@media (prefers-color-scheme: dark) {
:root {
--trace-bg: #181c20;
--trace-header-bg: #23272b;
--trace-border: #333;
--trace-text: #f1f1f1;
--trace-header: #202125;
}
}
.header {
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--trace-header);
user-select: none;
-webkit-app-region: drag;
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 10;
}
.headerIcon {
display: flex;
align-items: center;
width: content;
height: content;
margin-left: 12px;
}
.headerTitle {
flex: 1;
font-weight: 400;
font-size: 16px;
color: var(--trace-text);
pointer-events: none;
margin-left: 5px;
-webkit-app-region: drag;
}
.traceItem {
height: 32px;
padding-right: 5px;
padding-left: 5px;
}
.traceItem:hover {
background-color: var(--trace-header-bg);
cursor: pointer;
}
.headerOption {
display: 'flex';
align-items: 'center';
gap: 8;
width: 120;
justify-content: 'flex-end';
}
.tab-container_trace {
background: var(--trace-bg);
color: var(--trace-text);
}
.scroll-container,
.Box {
background: var(--trace-bg);
color: var(--trace-text);
}
.tab-header {
background: var(--trace-header-bg);
border-bottom: 1px solid var(--trace-border);
}
.error-text {
color: #d42817;
cursor: pointer;
user-select: none;
}
.floating {
position: fixed;
top: 40px;
left: 0px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
opacity: 0.9;
z-index: 2;
}
.default-text {
color: val(--trace-text);
cursor: pointer;
user-select: none;
}
.tab-header,
.scroll-container,
.Box {
transition:
background 0.2s,
color 0.2s,
box-shadow 0.2s;
}
.tab-container_trace {
width: 100%;
margin: 0 auto;
}
.tab-header {
display: flex;
border-bottom: 1px solid gray;
}
.tab-button {
padding: 20px 20px 10px;
background: var(--ai-color-bg-primary);
border: none;
cursor: pointer;
font-size: 16px;
}
.tab-button.active {
border-bottom: 2px solid #007bff;
color: #007bff;
}
.tab-content {
position: relative;
overflow: auto;
}
.closeButton {
-webkit-app-region: no-drag;
float: right;
position: fixed;
right: 10px;
top: 15px;
}
.scroll-container {
overflow-y: auto;
overflow-x: hidden;
height: calc(100vh - 40px);
}
.scroll-container::-webkit-scrollbar {
display: none;
}
.code-container {
display: block;
overflow-y: auto;
margin-top: 2px;
scrollbar-width: none;
border-top: '1px solid #eee';
width: '100%';
}
.code-container::-webkit-scrollbar {
display: none;
}
.content-button {
padding: 5px 10px 3px 10px;
border: 1px solid black;
border-radius: 10%;
cursor: pointer;
border-radius: 0;
background: var(--ai-color-bg-primary);
color: var(--trace-text);
}
.content-button.active {
background-color: #007bff;
color: white;
}
.settingsModal button[aria-label='Close'] {
-webkit-app-region: no-drag;
}
.trace-window {
width: 100%;
height: calc(100vh - 77px);
background-color: var(--trace-bg);
color: var(--trace-text);
opacity: 1;
box-shadow: var(--trace-shadow);
margin-top: 40px;
}
.table-header {
font-weight: bold;
align-items: center;
justify-content: center;
height: 24px;
font-size: 14px;
padding-top: 8px;
padding-bottom: 4px;
background-color: var(--trace-header-bg);
}
.code-context {
overflow-wrap: break-word;
word-break: break-all;
width: 100%;
white-space: pre-wrap;
}
.footer-link {
color: #666666;
cursor: pointer;
text-decoration: none;
display: block;
transition: color 0.2s;
}
.footer-link:hover {
color: #062afb;
text-decoration: underline;
}

View File

@ -0,0 +1,7 @@
import { SpanEntity } from '@mcp-trace/trace-core'
export interface TraceModal extends SpanEntity {
children: TraceModal[]
start: number
percent: number
}

View File

@ -0,0 +1,133 @@
import { TraceModal } from '@renderer/trace/pages/TraceModel'
import { Divider } from 'antd/lib'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { Box, GridItem, HStack, IconButton, SimpleGrid, Text } from './Component'
import { ProgressBar } from './ProgressBar'
interface TreeNodeProps {
node: TraceModal
handleClick: (nodeId: string) => void
treeData?: TraceModal[]
paddingLeft?: number
}
export const convertTime = (time: number | null): string => {
if (time == null) {
return ''
}
if (time > 100000) {
return `${(time / 1000).toFixed(0)}s`
}
if (time > 10000) {
return `${(time / 1000).toFixed(1)}s`
}
if (time > 1000) {
return `${(time / 1000).toFixed(2)}s`
}
if (time > 100) {
return `${time.toFixed(0)}ms`
}
if (time > 10) {
return `${time.toFixed(1)}ms`
}
return time.toFixed(2) + 'ms'
}
const TreeNode: React.FC<TreeNodeProps> = ({ node, handleClick, treeData, paddingLeft = 2 }) => {
const [isOpen, setIsOpen] = useState(true)
const hasChildren = node.children && node.children.length > 0
const [usedTime, setUsedTime] = useState('--')
// 只在 endTime 或 node 变化时更新 usedTime
useEffect(() => {
const endTime = node.endTime || Date.now()
setUsedTime(convertTime(endTime - node.startTime))
}, [node])
return (
<div
style={{
width: '100%'
}}>
<SimpleGrid
columns={20}
className="traceItem"
onClick={(e) => {
e.preventDefault()
handleClick(node.id)
}}>
<GridItem colSpan={8} style={{ paddingLeft: `${paddingLeft}px`, textAlign: 'left' }}>
<HStack grap={2}>
<IconButton
aria-label="Toggle"
aria-expanded={isOpen ? true : false}
size="sm"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setIsOpen(!isOpen)
}}
fontSize="10px"
style={{
margin: '0px',
visibility: hasChildren ? 'visible' : 'hidden'
}}
/>
<Text role="button" tabIndex={0} className={node.status === 'ERROR' ? 'error-text' : 'default-text'}>
{node.name}
</Text>
</HStack>
</GridItem>
{/* <GridItem padding={4} colSpan={3}>
<Text
// ml={2}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{node.attributes?.tags}
</Text>
</GridItem> */}
<GridItem colSpan={5}>
<Text style={{ color: 'red' }}>{node.usage ? '↑' + node.usage.prompt_tokens : ''}</Text>&nbsp;
<Text style={{ color: 'green' }}>{node.usage ? '↓' + node.usage.completion_tokens : ''}</Text>
</GridItem>
<GridItem colSpan={3}>
<Text /** ml={2} */>{usedTime}</Text>
</GridItem>
<GridItem padding={2} colSpan={4}>
<ProgressBar progress={Math.max(node.percent, 5)} start={node.start} />
</GridItem>
</SimpleGrid>
<Divider
orientation="end"
style={{
borderTop: '1px solid #ccc',
width: '100%',
margin: '0px 5px 0px 0px'
}}
/>
{hasChildren && isOpen && (
<Box>
{node.children &&
node.children
.sort((a, b) => a.startTime - b.startTime)
.map((childNode) => (
<TreeNode
key={childNode.id}
treeData={treeData}
node={childNode}
handleClick={handleClick}
paddingLeft={paddingLeft + 4}
/>
))}
</Box>
)}
</div>
)
}
export default TreeNode

View File

@ -0,0 +1,200 @@
import './Trace.css'
import { SpanEntity } from '@mcp-trace/trace-core'
import { TraceModal } from '@renderer/trace/pages/TraceModel'
import { Divider } from 'antd/lib'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Box, GridItem, SimpleGrid, Text, VStack } from './Component'
import SpanDetail from './SpanDetail'
import TraceTree from './TraceTree'
export interface TracePageProp {
topicId: string
traceId: string
modelName?: string
reload?: boolean
}
export const TracePage: React.FC<TracePageProp> = ({ topicId, traceId, modelName, reload = false }) => {
const [spans, setSpans] = useState<TraceModal[]>([])
const [selectNode, setSelectNode] = useState<TraceModal | null>(null)
const [showList, setShowList] = useState(true)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const { t } = useTranslation()
const mergeTraceModals = useCallback((oldNodes: TraceModal[], newNodes: TraceModal[]): TraceModal[] => {
const oldMap = new Map(oldNodes.map((n) => [n.id, n]))
return newNodes.map((newNode) => {
const oldNode = oldMap.get(newNode.id)
if (oldNode) {
// 如果旧节点已经结束,则直接返回旧节点
if (oldNode.endTime) {
return oldNode
}
oldNode.children = mergeTraceModals(oldNode.children, newNode.children)
Object.assign(oldNode, newNode)
return oldNode
} else {
return newNode
}
})
}, [])
const updatePercentAndStart = useCallback((nodes: TraceModal[], rootStart?: number, rootEnd?: number) => {
nodes.forEach((node) => {
const _rootStart = rootStart || node.startTime
const _rootEnd = rootEnd || node.endTime || Date.now()
const endTime = node.endTime || _rootEnd
const usedTime = endTime - node.startTime
const duration = _rootEnd - _rootStart
node.start = ((node.startTime - _rootStart) * 100) / duration
node.percent = duration === 0 ? 0 : (usedTime * 100) / duration
if (node.children) {
updatePercentAndStart(node.children, _rootStart, _rootEnd)
}
})
}, [])
const getRootSpan = (spans: SpanEntity[]): TraceModal[] => {
const map: Map<string, TraceModal> = new Map()
spans.map((span) => {
map.set(span.id, { ...span, children: [], percent: 100, start: 0 })
})
return Array.from(
map.values().filter((span) => {
if (span.parentId && map.has(span.parentId)) {
const parent = map.get(span.parentId)
if (parent) {
parent.children.push(span)
}
return false
}
return true
})
)
}
const findNodeById = useCallback((nodes: TraceModal[], id: string): TraceModal | null => {
for (const n of nodes) {
if (n.id === id) return n
if (n.children) {
const found = findNodeById(n.children, id)
if (found) return found
}
}
return null
}, [])
const getTraceData = useCallback(async (): Promise<boolean> => {
const datas = topicId && traceId ? await window.api.trace.getData(topicId, traceId, modelName) : []
const matchedSpans = getRootSpan(datas)
updatePercentAndStart(matchedSpans)
setSpans((prev) => mergeTraceModals(prev, matchedSpans))
const isEnded = !matchedSpans.find((e) => !e.endTime || e.endTime <= 0)
return isEnded
}, [topicId, traceId, modelName, updatePercentAndStart, mergeTraceModals])
const handleNodeClick = (nodeId: string) => {
const latestNode = findNodeById(spans, nodeId)
if (latestNode) {
setSelectNode(latestNode)
setShowList(false)
}
}
const handleShowList = () => {
setShowList(true)
setSelectNode(null)
}
useEffect(() => {
const handleShowTrace = async () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
const ended = await getTraceData()
// 只有未结束时才启动定时刷新
if (!ended) {
intervalRef.current = setInterval(async () => {
const endedInner = await getTraceData()
if (endedInner && intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, 300)
}
}
handleShowTrace()
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [getTraceData, traceId, topicId, reload])
useEffect(() => {
if (selectNode) {
const latest = findNodeById(spans, selectNode.id)
if (!latest) {
setShowList(true)
setSelectNode(null)
} else if (latest !== selectNode) {
setSelectNode(latest)
}
}
}, [spans, selectNode, findNodeById])
return (
<div className="trace-window">
<div className="tab-container_trace">
<SimpleGrid columns={1} templateColumns="1fr">
<Box padding={0} className="scroll-container">
{showList ? (
<VStack grap={1} align="start">
{spans.length === 0 ? (
<Text>Trace信息</Text>
) : (
<>
<SimpleGrid columns={20} style={{ width: '100%' }} className="floating">
<GridItem colSpan={8} padding={0} className={'table-header'}>
<Text tabIndex={0}>{t('trace.name')}</Text>
</GridItem>
<GridItem colSpan={5} padding={0} className={'table-header'}>
<Text>{t('trace.tokenUsage')}</Text>&nbsp;
</GridItem>
<GridItem colSpan={3} padding={0} className={'table-header'}>
<Text>{t('trace.spendTime')}</Text>
</GridItem>
<GridItem colSpan={4} padding={0} className={'table-header'}>
<Text></Text>
</GridItem>
</SimpleGrid>
<Divider
orientation="end"
style={{
width: '100%',
marginTop: '36px',
marginBottom: '0px'
}}
/>
{spans.map((node: TraceModal) => (
<TraceTree key={node.id} treeData={node.children} node={node} handleClick={handleNodeClick} />
))}
</>
)}
</VStack>
) : (
selectNode && <SpanDetail node={selectNode} clickShowModal={handleShowList} />
)}
</Box>
</SimpleGrid>
</div>
</div>
)
}

View File

@ -0,0 +1,66 @@
import i18n from '@renderer/i18n'
import { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { TraceIcon } from './pages/Component'
import { TracePage } from './pages/index'
const App = () => {
const [traceId, setTraceId] = useState('')
const [topicId, setTopicId] = useState('')
const [modelName, setModelName] = useState<string | undefined>(undefined)
const [reload, setReload] = useState(false)
const [title, setTitle] = useState('Call Chain Window')
useEffect(() => {
const setTraceHandler = (_, data) => {
if (data?.traceId && data?.topicId) {
setTraceId(data.traceId)
setTopicId(data.topicId)
setModelName(data.modelName)
setReload(!reload)
}
}
const setLangHandler = (_, data) => {
i18n.changeLanguage(data.lang)
const newTitle = i18n.t('trace.traceWindow')
if (newTitle !== title) {
window.api.trace.setTraceWindowTitle(i18n.t('trace.traceWindow'))
setTitle(newTitle)
}
}
const removeTraceHandler = window.electron.ipcRenderer.once('set-trace', setTraceHandler)
const removeLanguageHandler = window.electron.ipcRenderer.once('set-language', setLangHandler)
return () => {
removeTraceHandler()
removeLanguageHandler()
}
}, [title, reload, modelName, traceId, topicId])
const handleFooterClick = () => {
window.api.shell.openExternal('https://www.aliyun.com/product/edas')
}
return (
<>
<header className="header">
<div className="headerIcon">
<TraceIcon color="#e74c3c" size={24} />
</div>
<div className="headerTitle">{title}</div>
</header>
<TracePage traceId={traceId} topicId={topicId} reload={reload} modelName={modelName} />
<footer>
<span onClick={handleFooterClick} className="footer-link">
{i18n.t('trace.edasSupport')}
</span>
</footer>
</>
)
}
const root = createRoot(document.getElementById('root')!)
root.render(<App />)

View File

@ -0,0 +1,80 @@
import { Span } from '@opentelemetry/api'
export interface StartSpanParams {
topicId: string
name?: string
inputs?: any | any[]
tag?: string
parentSpanId?: string
modelName?: string
}
export interface EndSpanParams {
topicId: string
modelName?: string
outputs?: any | any[]
error?: Error
span?: Span
modelEnded?: boolean
}
export class ModelSpanEntity {
private modelName?: string
private spans: Span[] = []
private root?: Span
constructor(modelName?: string) {
this.modelName = modelName
}
getCurrentSpan(modelName?: string): Span | undefined {
if (modelName !== this.modelName) return undefined
return this.spans.length > 0 ? this.spans[this.spans.length - 1] : undefined
}
getRoot(): Span | undefined {
return this.root
}
addSpan(span: Span, isRoot = false) {
if (isRoot) {
this.root = span
}
this.spans.push(span)
}
removeSpan(span: Span) {
const index = this.spans.indexOf(span)
if (index !== -1) {
this.spans.splice(index, 1)
return true
}
return false
}
finishSpan() {
this.spans.forEach((span) => {
span.setAttribute('outputs', 'you paused')
span.end()
})
this.spans = []
}
getModelName() {
return this.modelName
}
getRootSpan() {
return this.spans && this.spans.length > 0 ? this.spans[0] : undefined
}
getSpanById(spanId?: string) {
return spanId ? this.spans.find((span) => span.spanContext().spanId === spanId) : undefined
}
addModelError(error: Error) {
this.spans.forEach((span) => {
span.recordException(error, Date.now())
})
}
}

View File

@ -545,6 +545,9 @@ export type WebSearchProvider = {
basicAuthUsername?: string basicAuthUsername?: string
basicAuthPassword?: string basicAuthPassword?: string
usingBrowser?: boolean usingBrowser?: boolean
topicId?: string
parentSpanId?: string
modelName?: string
} }
export type WebSearchProviderResult = { export type WebSearchProviderResult = {

View File

@ -189,6 +189,9 @@ export type Message = {
// 块集合 // 块集合
blocks: MessageBlock['id'][] blocks: MessageBlock['id'][]
// 跟踪Id
traceId?: string
} }
export interface Response { export interface Response {

View File

@ -3,6 +3,7 @@ import { Content, FunctionCall, Part, Tool, Type as GeminiSchemaType } from '@go
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { isFunctionCallingModel, isVisionModel } from '@renderer/config/models' import { isFunctionCallingModel, isVisionModel } from '@renderer/config/models'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { currentSpan } from '@renderer/services/SpanManagerService'
import store from '@renderer/store' import store from '@renderer/store'
import { addMCPServer } from '@renderer/store/mcp' import { addMCPServer } from '@renderer/store/mcp'
import { import {
@ -268,7 +269,11 @@ export function openAIToolsToMcpTool(
return tool return tool
} }
export async function callMCPTool(toolResponse: MCPToolResponse): Promise<MCPCallToolResponse> { export async function callMCPTool(
toolResponse: MCPToolResponse,
topicId?: string,
modelName?: string
): Promise<MCPCallToolResponse> {
logger.info(`Calling Tool: ${toolResponse.tool.serverName} ${toolResponse.tool.name}`, toolResponse.tool) logger.info(`Calling Tool: ${toolResponse.tool.serverName} ${toolResponse.tool.name}`, toolResponse.tool)
try { try {
const server = getMcpServerByTool(toolResponse.tool) const server = getMcpServerByTool(toolResponse.tool)
@ -277,12 +282,15 @@ export async function callMCPTool(toolResponse: MCPToolResponse): Promise<MCPCal
throw new Error(`Server not found: ${toolResponse.tool.serverName}`) throw new Error(`Server not found: ${toolResponse.tool.serverName}`)
} }
const resp = await window.api.mcp.callTool({ const resp = await window.api.mcp.callTool(
server, {
name: toolResponse.tool.name, server,
args: toolResponse.arguments, name: toolResponse.tool.name,
callId: toolResponse.id args: toolResponse.arguments,
}) callId: toolResponse.id
},
topicId ? currentSpan(topicId, modelName)?.spanContext() : undefined
)
if (toolResponse.tool.serverName === MCP_AUTO_INSTALL_SERVER_NAME) { if (toolResponse.tool.serverName === MCP_AUTO_INSTALL_SERVER_NAME) {
if (resp.data) { if (resp.data) {
const mcpServer: MCPServer = { const mcpServer: MCPServer = {
@ -533,7 +541,8 @@ export async function parseAndCallTools<R>(
convertToMessage: (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => R | undefined, convertToMessage: (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => R | undefined,
model: Model, model: Model,
mcpTools?: MCPTool[], mcpTools?: MCPTool[],
abortSignal?: AbortSignal abortSignal?: AbortSignal,
topicId?: CompletionsParams['topicId']
): Promise<{ toolResults: R[]; confirmedToolResponses: MCPToolResponse[] }> ): Promise<{ toolResults: R[]; confirmedToolResponses: MCPToolResponse[] }>
export async function parseAndCallTools<R>( export async function parseAndCallTools<R>(
@ -543,7 +552,8 @@ export async function parseAndCallTools<R>(
convertToMessage: (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => R | undefined, convertToMessage: (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => R | undefined,
model: Model, model: Model,
mcpTools?: MCPTool[], mcpTools?: MCPTool[],
abortSignal?: AbortSignal abortSignal?: AbortSignal,
topicId?: CompletionsParams['topicId']
): Promise<{ toolResults: R[]; confirmedToolResponses: MCPToolResponse[] }> ): Promise<{ toolResults: R[]; confirmedToolResponses: MCPToolResponse[] }>
export async function parseAndCallTools<R>( export async function parseAndCallTools<R>(
@ -553,7 +563,8 @@ export async function parseAndCallTools<R>(
convertToMessage: (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => R | undefined, convertToMessage: (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => R | undefined,
model: Model, model: Model,
mcpTools?: MCPTool[], mcpTools?: MCPTool[],
abortSignal?: AbortSignal abortSignal?: AbortSignal,
topicId?: CompletionsParams['topicId']
): Promise<{ toolResults: R[]; confirmedToolResponses: MCPToolResponse[] }> { ): Promise<{ toolResults: R[]; confirmedToolResponses: MCPToolResponse[] }> {
const toolResults: R[] = [] const toolResults: R[] = []
let curToolResponses: MCPToolResponse[] = [] let curToolResponses: MCPToolResponse[] = []
@ -616,7 +627,7 @@ export async function parseAndCallTools<R>(
// 执行工具调用 // 执行工具调用
try { try {
const images: string[] = [] const images: string[] = []
const toolCallResponse = await callMCPTool(toolResponse) const toolCallResponse = await callMCPTool(toolResponse, topicId, model.name)
// 立即更新为done状态 // 立即更新为done状态
upsertMCPToolResponse( upsertMCPToolResponse(

View File

@ -1,3 +1,4 @@
import { endTrace } from '@renderer/services/SpanManagerService'
import PQueue from 'p-queue' import PQueue from 'p-queue'
// Queue configuration - managed by topic // Queue configuration - managed by topic
@ -10,7 +11,11 @@ const requestQueues: { [topicId: string]: PQueue } = {}
* @returns A PQueue instance for the topic * @returns A PQueue instance for the topic
*/ */
export const getTopicQueue = (topicId: string, options = {}): PQueue => { export const getTopicQueue = (topicId: string, options = {}): PQueue => {
if (!requestQueues[topicId]) requestQueues[topicId] = new PQueue(options) if (!requestQueues[topicId]) {
requestQueues[topicId] = new PQueue(options).addListener('idle', () => {
endTrace({ topicId })
})
}
return requestQueues[topicId] return requestQueues[topicId]
} }

View File

@ -9,6 +9,7 @@ import {
getDefaultModel, getDefaultModel,
getDefaultTopic getDefaultTopic
} from '@renderer/services/AssistantService' } from '@renderer/services/AssistantService'
import { pauseTrace } from '@renderer/services/SpanManagerService'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import type { ActionItem } from '@renderer/types/selectionTypes' import type { ActionItem } from '@renderer/types/selectionTypes'
import { abortCompletion } from '@renderer/utils/abortController' import { abortCompletion } from '@renderer/utils/abortController'
@ -140,6 +141,9 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => {
abortCompletion(askId.current) abortCompletion(askId.current)
setIsLoading(false) setIsLoading(false)
} }
if (topicRef.current?.id) {
pauseTrace(topicRef.current.id)
}
} }
const handleRegenerate = () => { const handleRegenerate = () => {

View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
#root {
flex: 1 1 auto;
background: var(--trace-bg);
}
footer {
height: 36px;
text-align: center;
font-size: 12px;
color: grey;
background: var(--trace-header-bg);
flex-shrink: 0;
line-height: 36px;
position: relative;
z-index: 1
}
footer p {
margin: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/trace/traceWindow.tsx"></script>
</body>
</html>

View File

@ -1,4 +1,5 @@
{ {
"tsDecorders": "legacy",
"files": [], "files": [],
"references": [ "references": [
{ {
@ -7,5 +8,10 @@
{ {
"path": "./tsconfig.web.json" "path": "./tsconfig.web.json"
} }
] ],
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": true
}
} }

View File

@ -7,8 +7,9 @@
"src/main/env.d.ts", "src/main/env.d.ts",
"src/renderer/src/types/*", "src/renderer/src/types/*",
"packages/shared/**/*", "packages/shared/**/*",
"scripts" "scripts",
], "packages/mcp-trace/**/*",
"src/renderer/src/services/traceApi.ts" ],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"types": [ "types": [
@ -20,7 +21,11 @@
"@main/*": ["src/main/*"], "@main/*": ["src/main/*"],
"@types": ["src/renderer/src/types/index.ts"], "@types": ["src/renderer/src/types/index.ts"],
"@shared/*": ["packages/shared/*"], "@shared/*": ["packages/shared/*"],
"@logger": ["src/main/services/LoggerService"] "@logger": ["src/main/services/LoggerService"],
} "@mcp-trace/*": ["packages/mcp-trace/*"]
},
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": true
} }
} }

View File

@ -5,7 +5,8 @@
"src/preload/*.d.ts", "src/preload/*.d.ts",
"local/src/renderer/**/*", "local/src/renderer/**/*",
"packages/shared/**/*", "packages/shared/**/*",
"tests/__mocks__/**/*" "tests/__mocks__/**/*",
"packages/mcp-trace/**/*"
], ],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
@ -16,7 +17,11 @@
"@renderer/*": ["src/renderer/src/*"], "@renderer/*": ["src/renderer/src/*"],
"@shared/*": ["packages/shared/*"], "@shared/*": ["packages/shared/*"],
"@types": ["src/renderer/src/types/index.ts"], "@types": ["src/renderer/src/types/index.ts"],
"@logger": ["src/renderer/src/services/LoggerService"] "@logger": ["src/renderer/src/services/LoggerService"],
} "@mcp-trace/*": ["packages/mcp-trace/*"]
},
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": true
} }
} }

578
yarn.lock
View File

@ -872,7 +872,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.26.2": "@babel/code-frame@npm:^7.26.2":
version: 7.27.1 version: 7.27.1
resolution: "@babel/code-frame@npm:7.27.1" resolution: "@babel/code-frame@npm:7.27.1"
dependencies: dependencies:
@ -962,10 +962,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/helper-plugin-utils@npm:^7.25.9": "@babel/helper-plugin-utils@npm:^7.27.1":
version: 7.26.5 version: 7.27.1
resolution: "@babel/helper-plugin-utils@npm:7.26.5" resolution: "@babel/helper-plugin-utils@npm:7.27.1"
checksum: 10c0/cdaba71d4b891aa6a8dfbe5bac2f94effb13e5fa4c2c487667fdbaa04eae059b78b28d85a885071f45f7205aeb56d16759e1bed9c118b94b16e4720ef1ab0f65 checksum: 10c0/94cf22c81a0c11a09b197b41ab488d416ff62254ce13c57e62912c85700dc2e99e555225787a4099ff6bae7a1812d622c80fbaeda824b79baa10a6c5ac4cf69b
languageName: node languageName: node
linkType: hard linkType: hard
@ -1012,13 +1012,13 @@ __metadata:
linkType: hard linkType: hard
"@babel/plugin-transform-arrow-functions@npm:^7.25.9": "@babel/plugin-transform-arrow-functions@npm:^7.25.9":
version: 7.25.9 version: 7.27.1
resolution: "@babel/plugin-transform-arrow-functions@npm:7.25.9" resolution: "@babel/plugin-transform-arrow-functions@npm:7.27.1"
dependencies: dependencies:
"@babel/helper-plugin-utils": "npm:^7.25.9" "@babel/helper-plugin-utils": "npm:^7.27.1"
peerDependencies: peerDependencies:
"@babel/core": ^7.0.0-0 "@babel/core": ^7.0.0-0
checksum: 10c0/851fef9f58be60a80f46cc0ce1e46a6f7346a6f9d50fa9e0fa79d46ec205320069d0cc157db213e2bea88ef5b7d9bd7618bb83f0b1996a836e2426c3a3a1f622 checksum: 10c0/19abd7a7d11eef58c9340408a4c2594503f6c4eaea1baa7b0e5fbdda89df097e50663edb3448ad2300170b39efca98a75e5767af05cad3b0facb4944326896a3
languageName: node languageName: node
linkType: hard linkType: hard
@ -1029,6 +1029,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.20.13":
version: 7.27.4
resolution: "@babel/runtime@npm:7.27.4"
checksum: 10c0/ca99e964179c31615e1352e058cc9024df7111c829631c90eec84caba6703cc32acc81503771847c306b3c70b815609fe82dde8682936debe295b0b283b2dc6e
languageName: node
linkType: hard
"@babel/template@npm:^7.26.9, @babel/template@npm:^7.27.0": "@babel/template@npm:^7.26.9, @babel/template@npm:^7.27.0":
version: 7.27.0 version: 7.27.0
resolution: "@babel/template@npm:7.27.0" resolution: "@babel/template@npm:7.27.0"
@ -1055,7 +1062,27 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/types@npm:^7.25.4, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.0, @babel/types@npm:^7.27.7": "@babel/types@npm:^7.25.4, @babel/types@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/types@npm:7.27.1"
dependencies:
"@babel/helper-string-parser": "npm:^7.27.1"
"@babel/helper-validator-identifier": "npm:^7.27.1"
checksum: 10c0/ed736f14db2fdf0d36c539c8e06b6bb5e8f9649a12b5c0e1c516fed827f27ef35085abe08bf4d1302a4e20c9a254e762eed453bce659786d4a6e01ba26a91377
languageName: node
linkType: hard
"@babel/types@npm:^7.25.9, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.0":
version: 7.27.0
resolution: "@babel/types@npm:7.27.0"
dependencies:
"@babel/helper-string-parser": "npm:^7.25.9"
"@babel/helper-validator-identifier": "npm:^7.25.9"
checksum: 10c0/6f1592eabe243c89a608717b07b72969be9d9d2fce1dee21426238757ea1fa60fdfc09b29de9e48d8104311afc6e6fb1702565a9cc1e09bc1e76f2b2ddb0f6e1
languageName: node
linkType: hard
"@babel/types@npm:^7.27.7":
version: 7.27.7 version: 7.27.7
resolution: "@babel/types@npm:7.27.7" resolution: "@babel/types@npm:7.27.7"
dependencies: dependencies:
@ -3773,6 +3800,204 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@opentelemetry/api-logs@npm:0.200.0":
version: 0.200.0
resolution: "@opentelemetry/api-logs@npm:0.200.0"
dependencies:
"@opentelemetry/api": "npm:^1.3.0"
checksum: 10c0/c6bc3cfba35c69411f294519d93d0ff9f603517030d1162839ee42ac22ed1b0235edaf71d00cabc40125f813d8b4dc830d14315afcebcef138c1df560eaa5c91
languageName: node
linkType: hard
"@opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.9.0":
version: 1.9.0
resolution: "@opentelemetry/api@npm:1.9.0"
checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add
languageName: node
linkType: hard
"@opentelemetry/context-async-hooks@npm:2.0.1":
version: 2.0.1
resolution: "@opentelemetry/context-async-hooks@npm:2.0.1"
peerDependencies:
"@opentelemetry/api": ">=1.0.0 <1.10.0"
checksum: 10c0/75b06f33b9c3dccb8d9802c14badcc3b9a497b21c77bf0344fc6231041ea1bf6a2bcc195cc27fafd5914bffcc7fa160b9f4480c06a37e86e876c98bf1a533a0d
languageName: node
linkType: hard
"@opentelemetry/core@npm:2.0.0":
version: 2.0.0
resolution: "@opentelemetry/core@npm:2.0.0"
dependencies:
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
peerDependencies:
"@opentelemetry/api": ">=1.0.0 <1.10.0"
checksum: 10c0/d2cc6d8a955305b9de15cc36135e5d5b0f0405fead8bbd4de51433f2d05369af0a3bcb2c6fe7fe6d9e61b0db782511bcadc5d93ed906027d4c00d5c2e3575a24
languageName: node
linkType: hard
"@opentelemetry/core@npm:2.0.1":
version: 2.0.1
resolution: "@opentelemetry/core@npm:2.0.1"
dependencies:
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
peerDependencies:
"@opentelemetry/api": ">=1.0.0 <1.10.0"
checksum: 10c0/d587b1289559757d80da98039f9f57612f84f72ec608cd665dc467c7c6c5ce3a987dfcc2c63b521c7c86ce984a2552b3ead15a0dc458de1cf6bde5cdfe4ca9d8
languageName: node
linkType: hard
"@opentelemetry/exporter-trace-otlp-http@npm:^0.200.0":
version: 0.200.0
resolution: "@opentelemetry/exporter-trace-otlp-http@npm:0.200.0"
dependencies:
"@opentelemetry/core": "npm:2.0.0"
"@opentelemetry/otlp-exporter-base": "npm:0.200.0"
"@opentelemetry/otlp-transformer": "npm:0.200.0"
"@opentelemetry/resources": "npm:2.0.0"
"@opentelemetry/sdk-trace-base": "npm:2.0.0"
peerDependencies:
"@opentelemetry/api": ^1.3.0
checksum: 10c0/9cc914570cca1bd411e467f0a03146d3397c7940c8f9f5f876a28f9c8345f1b0d433651df8c8a0006d13c8b62f0d04ef44a7f7419d2765fcd061f1cbd585b6c5
languageName: node
linkType: hard
"@opentelemetry/otlp-exporter-base@npm:0.200.0":
version: 0.200.0
resolution: "@opentelemetry/otlp-exporter-base@npm:0.200.0"
dependencies:
"@opentelemetry/core": "npm:2.0.0"
"@opentelemetry/otlp-transformer": "npm:0.200.0"
peerDependencies:
"@opentelemetry/api": ^1.3.0
checksum: 10c0/3283c12bffc3156a41d9c16c097966e8418781a1d779250334f3d5b4f864be1aeac69fecfdf489abc95578dc36098dc0e026e5a48eb19ee170d72ef89b94f0e9
languageName: node
linkType: hard
"@opentelemetry/otlp-transformer@npm:0.200.0":
version: 0.200.0
resolution: "@opentelemetry/otlp-transformer@npm:0.200.0"
dependencies:
"@opentelemetry/api-logs": "npm:0.200.0"
"@opentelemetry/core": "npm:2.0.0"
"@opentelemetry/resources": "npm:2.0.0"
"@opentelemetry/sdk-logs": "npm:0.200.0"
"@opentelemetry/sdk-metrics": "npm:2.0.0"
"@opentelemetry/sdk-trace-base": "npm:2.0.0"
protobufjs: "npm:^7.3.0"
peerDependencies:
"@opentelemetry/api": ^1.3.0
checksum: 10c0/4f5383fad48c62e17824df91f6944b0376cb17f7b132b11d62fa5cf46747f224c980960209c85669b6e341a131f94586c6ad52bc1a6d2fb8d5295e23b460600c
languageName: node
linkType: hard
"@opentelemetry/resources@npm:2.0.0":
version: 2.0.0
resolution: "@opentelemetry/resources@npm:2.0.0"
dependencies:
"@opentelemetry/core": "npm:2.0.0"
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
peerDependencies:
"@opentelemetry/api": ">=1.3.0 <1.10.0"
checksum: 10c0/2f331ff8268ef7168e8f24312fd7505900693c0ea302f6025937e94c157b8173ee54f5d5a737c06b956da721aa63443ac520f530cade880ef3cd40a2a25c702c
languageName: node
linkType: hard
"@opentelemetry/resources@npm:2.0.1":
version: 2.0.1
resolution: "@opentelemetry/resources@npm:2.0.1"
dependencies:
"@opentelemetry/core": "npm:2.0.1"
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
peerDependencies:
"@opentelemetry/api": ">=1.3.0 <1.10.0"
checksum: 10c0/96532b7553b26607a7a892d72f6b03ad12bd542dc23c95135a8ae40362da9c883c21a4cff3d2296d9e0e9bd899a5977e325ed52d83142621a8ffe81d08d99341
languageName: node
linkType: hard
"@opentelemetry/sdk-logs@npm:0.200.0":
version: 0.200.0
resolution: "@opentelemetry/sdk-logs@npm:0.200.0"
dependencies:
"@opentelemetry/api-logs": "npm:0.200.0"
"@opentelemetry/core": "npm:2.0.0"
"@opentelemetry/resources": "npm:2.0.0"
peerDependencies:
"@opentelemetry/api": ">=1.4.0 <1.10.0"
checksum: 10c0/031dc40dd012fad102e5c8c0c9bdbbce051dbc7fcc2e05e003f959aeb34d252dc3595b353ea2a9f900ff40f45d19cb4c8f7ab95a9faa01391f6b415c7780c786
languageName: node
linkType: hard
"@opentelemetry/sdk-metrics@npm:2.0.0":
version: 2.0.0
resolution: "@opentelemetry/sdk-metrics@npm:2.0.0"
dependencies:
"@opentelemetry/core": "npm:2.0.0"
"@opentelemetry/resources": "npm:2.0.0"
peerDependencies:
"@opentelemetry/api": ">=1.9.0 <1.10.0"
checksum: 10c0/9a3c87738671f29a496a39d65b3ab0829b52d0f31c0be662ea575a8f77bc5444044fd01513c891abdff6bf6344a08730e18f79253a85e68962669f3e1fa12e72
languageName: node
linkType: hard
"@opentelemetry/sdk-trace-base@npm:2.0.0":
version: 2.0.0
resolution: "@opentelemetry/sdk-trace-base@npm:2.0.0"
dependencies:
"@opentelemetry/core": "npm:2.0.0"
"@opentelemetry/resources": "npm:2.0.0"
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
peerDependencies:
"@opentelemetry/api": ">=1.3.0 <1.10.0"
checksum: 10c0/c63cc052741e4cc01d084c883e24a1c0792f081a242e14e5cf526d5a3d96bac5974006fa0d8f902bd04f34ed9ce95a0d0f01b7fdb37fcc813cea9f818f2b8f43
languageName: node
linkType: hard
"@opentelemetry/sdk-trace-base@npm:2.0.1, @opentelemetry/sdk-trace-base@npm:^2.0.0":
version: 2.0.1
resolution: "@opentelemetry/sdk-trace-base@npm:2.0.1"
dependencies:
"@opentelemetry/core": "npm:2.0.1"
"@opentelemetry/resources": "npm:2.0.1"
"@opentelemetry/semantic-conventions": "npm:^1.29.0"
peerDependencies:
"@opentelemetry/api": ">=1.3.0 <1.10.0"
checksum: 10c0/4e3c733296012b758d007e9c0d8a5b175edbe9a680c73ec75303476e7982b73ad4209f1a2791c1a94c428e5a53eba6c2a72faa430c70336005aa58744d6cb37b
languageName: node
linkType: hard
"@opentelemetry/sdk-trace-node@npm:^2.0.0":
version: 2.0.1
resolution: "@opentelemetry/sdk-trace-node@npm:2.0.1"
dependencies:
"@opentelemetry/context-async-hooks": "npm:2.0.1"
"@opentelemetry/core": "npm:2.0.1"
"@opentelemetry/sdk-trace-base": "npm:2.0.1"
peerDependencies:
"@opentelemetry/api": ">=1.0.0 <1.10.0"
checksum: 10c0/b237efc219dc10c33746c05461c8c8741edbe7558eaf7f2dab01a3e75af4788bfd0633a049cd5dc7ecf015a2de7aa948c3989c0131d1f140109fb5e7b0313d7a
languageName: node
linkType: hard
"@opentelemetry/sdk-trace-web@npm:^2.0.0":
version: 2.0.1
resolution: "@opentelemetry/sdk-trace-web@npm:2.0.1"
dependencies:
"@opentelemetry/core": "npm:2.0.1"
"@opentelemetry/sdk-trace-base": "npm:2.0.1"
peerDependencies:
"@opentelemetry/api": ">=1.0.0 <1.10.0"
checksum: 10c0/48821b91430e24378b0b5b2632e78efdd018a3f840462a6aeba6ce318a6480bad2f623cc7f7f625a9266028ad44b78eb8456181778de6cb18725f26c44e2729b
languageName: node
linkType: hard
"@opentelemetry/semantic-conventions@npm:^1.29.0":
version: 1.34.0
resolution: "@opentelemetry/semantic-conventions@npm:1.34.0"
checksum: 10c0/a51a32a5cf5c803bd2125a680d0abacbff632f3b255d0fe52379dac191114a0e8d72a34f9c46c5483ccfe91c4061c309f3cf61a19d11347e2a69779e82cfefd0
languageName: node
linkType: hard
"@parcel/watcher-android-arm64@npm:2.5.1": "@parcel/watcher-android-arm64@npm:2.5.1":
version: 2.5.1 version: 2.5.1
resolution: "@parcel/watcher-android-arm64@npm:2.5.1" resolution: "@parcel/watcher-android-arm64@npm:2.5.1"
@ -3949,6 +4174,79 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2":
version: 1.1.2
resolution: "@protobufjs/aspromise@npm:1.1.2"
checksum: 10c0/a83343a468ff5b5ec6bff36fd788a64c839e48a07ff9f4f813564f58caf44d011cd6504ed2147bf34835bd7a7dd2107052af755961c6b098fd8902b4f6500d0f
languageName: node
linkType: hard
"@protobufjs/base64@npm:^1.1.2":
version: 1.1.2
resolution: "@protobufjs/base64@npm:1.1.2"
checksum: 10c0/eec925e681081af190b8ee231f9bad3101e189abbc182ff279da6b531e7dbd2a56f1f306f37a80b1be9e00aa2d271690d08dcc5f326f71c9eed8546675c8caf6
languageName: node
linkType: hard
"@protobufjs/codegen@npm:^2.0.4":
version: 2.0.4
resolution: "@protobufjs/codegen@npm:2.0.4"
checksum: 10c0/26ae337c5659e41f091606d16465bbcc1df1f37cc1ed462438b1f67be0c1e28dfb2ca9f294f39100c52161aef82edf758c95d6d75650a1ddf31f7ddee1440b43
languageName: node
linkType: hard
"@protobufjs/eventemitter@npm:^1.1.0":
version: 1.1.0
resolution: "@protobufjs/eventemitter@npm:1.1.0"
checksum: 10c0/1eb0a75180e5206d1033e4138212a8c7089a3d418c6dfa5a6ce42e593a4ae2e5892c4ef7421f38092badba4040ea6a45f0928869989411001d8c1018ea9a6e70
languageName: node
linkType: hard
"@protobufjs/fetch@npm:^1.1.0":
version: 1.1.0
resolution: "@protobufjs/fetch@npm:1.1.0"
dependencies:
"@protobufjs/aspromise": "npm:^1.1.1"
"@protobufjs/inquire": "npm:^1.1.0"
checksum: 10c0/cda6a3dc2d50a182c5865b160f72077aac197046600091dbb005dd0a66db9cce3c5eaed6d470ac8ed49d7bcbeef6ee5f0bc288db5ff9a70cbd003e5909065233
languageName: node
linkType: hard
"@protobufjs/float@npm:^1.0.2":
version: 1.0.2
resolution: "@protobufjs/float@npm:1.0.2"
checksum: 10c0/18f2bdede76ffcf0170708af15c9c9db6259b771e6b84c51b06df34a9c339dbbeec267d14ce0bddd20acc142b1d980d983d31434398df7f98eb0c94a0eb79069
languageName: node
linkType: hard
"@protobufjs/inquire@npm:^1.1.0":
version: 1.1.0
resolution: "@protobufjs/inquire@npm:1.1.0"
checksum: 10c0/64372482efcba1fb4d166a2664a6395fa978b557803857c9c03500e0ac1013eb4b1aacc9ed851dd5fc22f81583670b4f4431bae186f3373fedcfde863ef5921a
languageName: node
linkType: hard
"@protobufjs/path@npm:^1.1.2":
version: 1.1.2
resolution: "@protobufjs/path@npm:1.1.2"
checksum: 10c0/cece0a938e7f5dfd2fa03f8c14f2f1cf8b0d6e13ac7326ff4c96ea311effd5fb7ae0bba754fbf505312af2e38500250c90e68506b97c02360a43793d88a0d8b4
languageName: node
linkType: hard
"@protobufjs/pool@npm:^1.1.0":
version: 1.1.0
resolution: "@protobufjs/pool@npm:1.1.0"
checksum: 10c0/eda2718b7f222ac6e6ad36f758a92ef90d26526026a19f4f17f668f45e0306a5bd734def3f48f51f8134ae0978b6262a5c517c08b115a551756d1a3aadfcf038
languageName: node
linkType: hard
"@protobufjs/utf8@npm:^1.1.0":
version: 1.1.0
resolution: "@protobufjs/utf8@npm:1.1.0"
checksum: 10c0/a3fe31fe3fa29aa3349e2e04ee13dc170cc6af7c23d92ad49e3eeaf79b9766264544d3da824dba93b7855bd6a2982fb40032ef40693da98a136d835752beb487
languageName: node
linkType: hard
"@rc-component/async-validator@npm:^5.0.3": "@rc-component/async-validator@npm:^5.0.3":
version: 5.0.4 version: 5.0.4
resolution: "@rc-component/async-validator@npm:5.0.4" resolution: "@rc-component/async-validator@npm:5.0.4"
@ -5796,6 +6094,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/node@npm:>=13.7.0":
version: 22.15.29
resolution: "@types/node@npm:22.15.29"
dependencies:
undici-types: "npm:~6.21.0"
checksum: 10c0/602cc88c6150780cd9b5b44604754e0ce13983ae876a538861d6ecfb1511dff289e5576fffd26c841cde2142418d4bb76e2a72a382b81c04557ccb17cff29e1d
languageName: node
linkType: hard
"@types/node@npm:^18.11.18, @types/node@npm:^18.19.9": "@types/node@npm:^18.11.18, @types/node@npm:^18.19.9":
version: 18.19.86 version: 18.19.86
resolution: "@types/node@npm:18.19.86" resolution: "@types/node@npm:18.19.86"
@ -6881,6 +7188,12 @@ __metadata:
"@modelcontextprotocol/sdk": "npm:^1.12.3" "@modelcontextprotocol/sdk": "npm:^1.12.3"
"@mozilla/readability": "npm:^0.6.0" "@mozilla/readability": "npm:^0.6.0"
"@notionhq/client": "npm:^2.2.15" "@notionhq/client": "npm:^2.2.15"
"@opentelemetry/api": "npm:^1.9.0"
"@opentelemetry/core": "npm:2.0.0"
"@opentelemetry/exporter-trace-otlp-http": "npm:^0.200.0"
"@opentelemetry/sdk-trace-base": "npm:^2.0.0"
"@opentelemetry/sdk-trace-node": "npm:^2.0.0"
"@opentelemetry/sdk-trace-web": "npm:^2.0.0"
"@playwright/test": "npm:^1.52.0" "@playwright/test": "npm:^1.52.0"
"@reduxjs/toolkit": "npm:^2.2.5" "@reduxjs/toolkit": "npm:^2.2.5"
"@shikijs/markdown-it": "npm:^3.7.0" "@shikijs/markdown-it": "npm:^3.7.0"
@ -6970,7 +7283,7 @@ __metadata:
node-stream-zip: "npm:^1.15.0" node-stream-zip: "npm:^1.15.0"
notion-helper: "npm:^1.3.22" notion-helper: "npm:^1.3.22"
npx-scope-finder: "npm:^1.2.0" npx-scope-finder: "npm:^1.2.0"
officeparser: "npm:^4.1.1" officeparser: "npm:^4.2.0"
openai: "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch" openai: "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch"
os-proxy-config: "npm:^1.1.2" os-proxy-config: "npm:^1.1.2"
p-queue: "npm:^8.1.0" p-queue: "npm:^8.1.0"
@ -6985,6 +7298,7 @@ __metadata:
react-hotkeys-hook: "npm:^4.6.1" react-hotkeys-hook: "npm:^4.6.1"
react-i18next: "npm:^14.1.2" react-i18next: "npm:^14.1.2"
react-infinite-scroll-component: "npm:^6.1.0" react-infinite-scroll-component: "npm:^6.1.0"
react-json-view: "npm:^1.21.3"
react-markdown: "npm:^10.1.0" react-markdown: "npm:^10.1.0"
react-redux: "npm:^9.1.2" react-redux: "npm:^9.1.2"
react-router: "npm:6" react-router: "npm:6"
@ -6993,6 +7307,7 @@ __metadata:
react-window: "npm:^1.8.11" react-window: "npm:^1.8.11"
redux: "npm:^5.0.1" redux: "npm:^5.0.1"
redux-persist: "npm:^6.0.0" redux-persist: "npm:^6.0.0"
reflect-metadata: "npm:0.2.2"
rehype-katex: "npm:^7.0.1" rehype-katex: "npm:^7.0.1"
rehype-mathjax: "npm:^7.1.0" rehype-mathjax: "npm:^7.1.0"
rehype-raw: "npm:^7.0.0" rehype-raw: "npm:^7.0.0"
@ -7515,6 +7830,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"asap@npm:~2.0.3":
version: 2.0.6
resolution: "asap@npm:2.0.6"
checksum: 10c0/c6d5e39fe1f15e4b87677460bd66b66050cd14c772269cee6688824c1410a08ab20254bb6784f9afb75af9144a9f9a7692d49547f4d19d715aeb7c0318f3136d
languageName: node
linkType: hard
"assert-plus@npm:^1.0.0": "assert-plus@npm:^1.0.0":
version: 1.0.0 version: 1.0.0
resolution: "assert-plus@npm:1.0.0" resolution: "assert-plus@npm:1.0.0"
@ -7642,6 +7964,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"base16@npm:^1.0.0":
version: 1.0.0
resolution: "base16@npm:1.0.0"
checksum: 10c0/af1aee7b297d968528ef47c8de2c5274029743e8a4a5f61ec823e36b673781691d124168cb22936c7997f53d89b344c58bf7ecf93eeb148cffa7e3fb4e4b8b18
languageName: node
linkType: hard
"base64-js@npm:^1.3.0, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": "base64-js@npm:^1.3.0, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1":
version: 1.5.1 version: 1.5.1
resolution: "base64-js@npm:1.5.1" resolution: "base64-js@npm:1.5.1"
@ -8802,6 +9131,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"cross-fetch@npm:^3.1.5":
version: 3.2.0
resolution: "cross-fetch@npm:3.2.0"
dependencies:
node-fetch: "npm:^2.7.0"
checksum: 10c0/d8596adf0269130098a676f6739a0922f3cc7b71cc89729925411ebe851a87026171c82ea89154c4811c9867c01c44793205a52e618ce2684650218c7fbeeb9f
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": "cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6":
version: 7.0.6 version: 7.0.6
resolution: "cross-spawn@npm:7.0.6" resolution: "cross-spawn@npm:7.0.6"
@ -10992,6 +11330,37 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"fbemitter@npm:^3.0.0":
version: 3.0.0
resolution: "fbemitter@npm:3.0.0"
dependencies:
fbjs: "npm:^3.0.0"
checksum: 10c0/f130dd8e15dc3fc6709a26586b7a589cd994e1d1024b624f2cc8ef1b12401536a94bb30038e68150a24f9ba18863e9a3fe87941ade2c87667bfbd17f4848d5c7
languageName: node
linkType: hard
"fbjs-css-vars@npm:^1.0.0":
version: 1.0.2
resolution: "fbjs-css-vars@npm:1.0.2"
checksum: 10c0/dfb64116b125a64abecca9e31477b5edb9a2332c5ffe74326fe36e0a72eef7fc8a49b86adf36c2c293078d79f4524f35e80f5e62546395f53fb7c9e69821f54f
languageName: node
linkType: hard
"fbjs@npm:^3.0.0, fbjs@npm:^3.0.1":
version: 3.0.5
resolution: "fbjs@npm:3.0.5"
dependencies:
cross-fetch: "npm:^3.1.5"
fbjs-css-vars: "npm:^1.0.0"
loose-envify: "npm:^1.0.0"
object-assign: "npm:^4.1.0"
promise: "npm:^7.1.1"
setimmediate: "npm:^1.0.5"
ua-parser-js: "npm:^1.0.35"
checksum: 10c0/66d0a2fc9a774f9066e35ac2ac4bf1245931d27f3ac287c7d47e6aa1fc152b243c2109743eb8f65341e025621fb51a12038fadb9fd8fda2e3ddae04ebab06f91
languageName: node
linkType: hard
"fd-slicer@npm:~1.1.0": "fd-slicer@npm:~1.1.0":
version: 1.1.0 version: 1.1.0
resolution: "fd-slicer@npm:1.1.0" resolution: "fd-slicer@npm:1.1.0"
@ -11217,6 +11586,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"flux@npm:^4.0.1":
version: 4.0.4
resolution: "flux@npm:4.0.4"
dependencies:
fbemitter: "npm:^3.0.0"
fbjs: "npm:^3.0.1"
peerDependencies:
react: ^15.0.2 || ^16.0.0 || ^17.0.0
checksum: 10c0/948bc01b97ff21babc8bfe5c40543d643ca126b71edd447a9ac051b05d20fd549a6bcc4afe043bcde56201782e688a5eaeda1bd8e3e58915641abdd5b3ea21e0
languageName: node
linkType: hard
"follow-redirects@npm:^1.15.6": "follow-redirects@npm:^1.15.6":
version: 1.15.9 version: 1.15.9
resolution: "follow-redirects@npm:1.15.9" resolution: "follow-redirects@npm:1.15.9"
@ -12805,7 +13186,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"js-tokens@npm:^4.0.0": "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0 version: 4.0.0
resolution: "js-tokens@npm:4.0.0" resolution: "js-tokens@npm:4.0.0"
checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed
@ -13414,6 +13795,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lodash.curry@npm:^4.0.1":
version: 4.1.1
resolution: "lodash.curry@npm:4.1.1"
checksum: 10c0/f0431947dc9236df879fc13eb40c31a2839c958bd0eaa39170a5758c25a7d85d461716a851ab45a175371950b283480615cdd4b07fb0dd1afff7a2914a90696f
languageName: node
linkType: hard
"lodash.escaperegexp@npm:^4.1.2": "lodash.escaperegexp@npm:^4.1.2":
version: 4.1.2 version: 4.1.2
resolution: "lodash.escaperegexp@npm:4.1.2" resolution: "lodash.escaperegexp@npm:4.1.2"
@ -13421,6 +13809,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lodash.flow@npm:^3.3.0":
version: 3.5.0
resolution: "lodash.flow@npm:3.5.0"
checksum: 10c0/b3202ddbb79e5aab41719806d0d5ae969f64ae6b59e6bdaaecaa96ec68d6ba429e544017fe0e71ecf5b7ee3cea7b45d43c46b7d67ca159d6cca86fca76c61a31
languageName: node
linkType: hard
"lodash.isequal@npm:^4.5.0": "lodash.isequal@npm:^4.5.0":
version: 4.5.0 version: 4.5.0
resolution: "lodash.isequal@npm:4.5.0" resolution: "lodash.isequal@npm:4.5.0"
@ -13479,6 +13874,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"long@npm:^5.0.0":
version: 5.3.2
resolution: "long@npm:5.3.2"
checksum: 10c0/7130fe1cbce2dca06734b35b70d380ca3f70271c7f8852c922a7c62c86c4e35f0c39290565eca7133c625908d40e126ac57c02b1b1a4636b9457d77e1e60b981
languageName: node
linkType: hard
"longest-streak@npm:^2.0.0": "longest-streak@npm:^2.0.0":
version: 2.0.4 version: 2.0.4
resolution: "longest-streak@npm:2.0.4" resolution: "longest-streak@npm:2.0.4"
@ -13493,6 +13895,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"loose-envify@npm:^1.0.0":
version: 1.4.0
resolution: "loose-envify@npm:1.4.0"
dependencies:
js-tokens: "npm:^3.0.0 || ^4.0.0"
bin:
loose-envify: cli.js
checksum: 10c0/655d110220983c1a4b9c0c679a2e8016d4b67f6e9c7b5435ff5979ecdb20d0813f4dec0a08674fcbdd4846a3f07edbb50a36811fd37930b94aaa0d9daceb017e
languageName: node
linkType: hard
"lop@npm:^0.4.1": "lop@npm:^0.4.1":
version: 0.4.2 version: 0.4.2
resolution: "lop@npm:0.4.2" resolution: "lop@npm:0.4.2"
@ -15261,7 +15674,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9": "node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9, node-fetch@npm:^2.7.0":
version: 2.7.0 version: 2.7.0
resolution: "node-fetch@npm:2.7.0" resolution: "node-fetch@npm:2.7.0"
dependencies: dependencies:
@ -15480,7 +15893,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"officeparser@npm:^4.1.1": "officeparser@npm:^4.2.0":
version: 4.2.0 version: 4.2.0
resolution: "officeparser@npm:4.2.0" resolution: "officeparser@npm:4.2.0"
dependencies: dependencies:
@ -16394,6 +16807,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"promise@npm:^7.1.1":
version: 7.3.1
resolution: "promise@npm:7.3.1"
dependencies:
asap: "npm:~2.0.3"
checksum: 10c0/742e5c0cc646af1f0746963b8776299701ad561ce2c70b49365d62c8db8ea3681b0a1bf0d4e2fe07910bf72f02d39e51e8e73dc8d7503c3501206ac908be107f
languageName: node
linkType: hard
"property-information@npm:^6.0.0": "property-information@npm:^6.0.0":
version: 6.5.0 version: 6.5.0
resolution: "property-information@npm:6.5.0" resolution: "property-information@npm:6.5.0"
@ -16408,6 +16830,26 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"protobufjs@npm:^7.3.0":
version: 7.5.2
resolution: "protobufjs@npm:7.5.2"
dependencies:
"@protobufjs/aspromise": "npm:^1.1.2"
"@protobufjs/base64": "npm:^1.1.2"
"@protobufjs/codegen": "npm:^2.0.4"
"@protobufjs/eventemitter": "npm:^1.1.0"
"@protobufjs/fetch": "npm:^1.1.0"
"@protobufjs/float": "npm:^1.0.2"
"@protobufjs/inquire": "npm:^1.1.0"
"@protobufjs/path": "npm:^1.1.2"
"@protobufjs/pool": "npm:^1.1.0"
"@protobufjs/utf8": "npm:^1.1.0"
"@types/node": "npm:>=13.7.0"
long: "npm:^5.0.0"
checksum: 10c0/c4ac6298280dfe6116e7a1b722310de807037b1abed816db2af2a595ddc9dc6160447eebd4ad8e2968d41f0f85375f62e893adfe760af1a943d195931c7bb875
languageName: node
linkType: hard
"proxy-addr@npm:^2.0.7": "proxy-addr@npm:^2.0.7":
version: 2.0.7 version: 2.0.7
resolution: "proxy-addr@npm:2.0.7" resolution: "proxy-addr@npm:2.0.7"
@ -16465,6 +16907,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"pure-color@npm:^1.2.0":
version: 1.3.0
resolution: "pure-color@npm:1.3.0"
checksum: 10c0/50d0e088ad0349bdd508cddf7c7afbb2d14ba3c047628dbfcfddf467a98f10462caf91f3227172ada88f64afaf761c499ecba0d4053b06926f0f914769be24b9
languageName: node
linkType: hard
"qs@npm:^6.14.0": "qs@npm:^6.14.0":
version: 6.14.0 version: 6.14.0
resolution: "qs@npm:6.14.0" resolution: "qs@npm:6.14.0"
@ -17076,6 +17525,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-base16-styling@npm:^0.6.0":
version: 0.6.0
resolution: "react-base16-styling@npm:0.6.0"
dependencies:
base16: "npm:^1.0.0"
lodash.curry: "npm:^4.0.1"
lodash.flow: "npm:^3.3.0"
pure-color: "npm:^1.2.0"
checksum: 10c0/4887ac57b36fedc7e1ebc99ae431c5feb07d60a9150770d0ca3a59f4ae7059434ea8813ca4f915e7434d4d8d8529b9ba072ceb85041fd52ca1cd6289c57c9621
languageName: node
linkType: hard
"react-dom@npm:^19.0.0": "react-dom@npm:^19.0.0":
version: 19.1.0 version: 19.1.0
resolution: "react-dom@npm:19.1.0" resolution: "react-dom@npm:19.1.0"
@ -17147,6 +17608,28 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-json-view@npm:^1.21.3":
version: 1.21.3
resolution: "react-json-view@npm:1.21.3"
dependencies:
flux: "npm:^4.0.1"
react-base16-styling: "npm:^0.6.0"
react-lifecycles-compat: "npm:^3.0.4"
react-textarea-autosize: "npm:^8.3.2"
peerDependencies:
react: ^17.0.0 || ^16.3.0 || ^15.5.4
react-dom: ^17.0.0 || ^16.3.0 || ^15.5.4
checksum: 10c0/f41b38e599f148cf922f60390e56bb821f17a091373b08310fd82ebc526428683011751aa023687041481a46b20aeb1c47f660979d43db77674486aec9dc1d3f
languageName: node
linkType: hard
"react-lifecycles-compat@npm:^3.0.4":
version: 3.0.4
resolution: "react-lifecycles-compat@npm:3.0.4"
checksum: 10c0/1d0df3c85af79df720524780f00c064d53a9dd1899d785eddb7264b378026979acbddb58a4b7e06e7d0d12aa1494fd5754562ee55d32907b15601068dae82c27
languageName: node
linkType: hard
"react-markdown@npm:^10.1.0": "react-markdown@npm:^10.1.0":
version: 10.1.0 version: 10.1.0
resolution: "react-markdown@npm:10.1.0" resolution: "react-markdown@npm:10.1.0"
@ -17254,6 +17737,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-textarea-autosize@npm:^8.3.2":
version: 8.5.9
resolution: "react-textarea-autosize@npm:8.5.9"
dependencies:
"@babel/runtime": "npm:^7.20.13"
use-composed-ref: "npm:^1.3.0"
use-latest: "npm:^1.2.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/3a924db59259a6e3b834dcddc12a8661b43dcdaa1b43c41c732e0548bb2761e9a53dbc6db4e0d9e237728b4869e42c25e5417408b0391aec290c90874733a09f
languageName: node
linkType: hard
"react-window@npm:^1.8.11": "react-window@npm:^1.8.11":
version: 1.8.11 version: 1.8.11
resolution: "react-window@npm:1.8.11" resolution: "react-window@npm:1.8.11"
@ -17393,6 +17889,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"reflect-metadata@npm:0.2.2":
version: 0.2.2
resolution: "reflect-metadata@npm:0.2.2"
checksum: 10c0/1cd93a15ea291e420204955544637c264c216e7aac527470e393d54b4bb075f10a17e60d8168ec96600c7e0b9fcc0cb0bb6e91c3fbf5b0d8c9056f04e6ac1ec2
languageName: node
linkType: hard
"regex-recursion@npm:^6.0.2": "regex-recursion@npm:^6.0.2":
version: 6.0.2 version: 6.0.2
resolution: "regex-recursion@npm:6.0.2" resolution: "regex-recursion@npm:6.0.2"
@ -19361,6 +19864,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ua-parser-js@npm:^1.0.35":
version: 1.0.40
resolution: "ua-parser-js@npm:1.0.40"
bin:
ua-parser-js: script/cli.js
checksum: 10c0/2b6ac642c74323957dae142c31f72287f2420c12dced9603d989b96c132b80232779c429b296d7de4012ef8b64e0d8fadc53c639ef06633ce13d785a78b5be6c
languageName: node
linkType: hard
"uc.micro@npm:^2.0.0, uc.micro@npm:^2.1.0": "uc.micro@npm:^2.0.0, uc.micro@npm:^2.1.0":
version: 2.1.0 version: 2.1.0
resolution: "uc.micro@npm:2.1.0" resolution: "uc.micro@npm:2.1.0"
@ -19653,6 +20165,44 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"use-composed-ref@npm:^1.3.0":
version: 1.4.0
resolution: "use-composed-ref@npm:1.4.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/c77e0cba9579b7746d52feaf3ce77d8c345f266c9c1ef46584ae68f54646537c87b2ad97f5219a4b1db52f97ec2905e88e5b146add1f28f7e457bd52ca1b93cf
languageName: node
linkType: hard
"use-isomorphic-layout-effect@npm:^1.1.1":
version: 1.2.1
resolution: "use-isomorphic-layout-effect@npm:1.2.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/4d3c1124d630fbe09c1d2a16af0cd78ac2fe1d22eb24a178363e3d84a897659cc04e8e8cd71f66ff78ff75ef8287fa72e746cb213b96c1097e70e4b4ed69f63f
languageName: node
linkType: hard
"use-latest@npm:^1.2.1":
version: 1.3.0
resolution: "use-latest@npm:1.3.0"
dependencies:
use-isomorphic-layout-effect: "npm:^1.1.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/067c648814ad0c1f1e89d2d0e496254b05c4bed6a34e23045b4413824222aab08fd803c59a42852acc16830c17567d03f8c90af0a62be2f4e4b931454d079798
languageName: node
linkType: hard
"use-memo-one@npm:^1.1.3": "use-memo-one@npm:^1.1.3":
version: 1.1.3 version: 1.1.3
resolution: "use-memo-one@npm:1.1.3" resolution: "use-memo-one@npm:1.1.3"