feat: 全链路优化 — 死锁修复、MD3主题、上下文持久化、群聊自然化、打字状态、知识库
**死锁根因修复** - periodicThinkLoop:1015 orphaned lock → 删除(字段已原子化) - RecordUserMessage 隔离为 recordMu - atomic.Int64 替换 lastUserMessage/lastThinkTime 等 **MD3 / Android 17 主题** - 毛玻璃卡片 (backdrop-filter) - MD3 色彩令牌 (pink primary #f472b6) - icons.js 独立矢量图标库 + 运行时 emoji 替换 - 无边框卡片、圆角按钮、阴影层次 **上下文持久化** - AddMessage → saveToDB 异步写 PostgreSQL - LoadFromDB 恢复 (admin-session-main + 懒加载) - LLMMessage.Timestamp 字段 **群聊与适配器** - group_ambient 模式: 非@消息让 LLM 自己判断是否插话 - 戳一戳动作消息总是回复 - NapCat 打字状态 (set_input_status, 最小3秒显示) - HTTP API 配置 (http_url/http_token) **知识库 & 防编造** - knowledge.CanHandle 对 chat 意图也触发 - 关键词预筛选避免无关 embedding 调用 - persona + synthesizer 三重诚实规则 - 工具结果持久化到会话历史 **平台桥接器** - detached:true Go进程独立存活 - ethend 重启自动接管已运行服务 - stop() 接管模式 taskkill/F/ PID - Windows netstat 替代 fuser 获取 PID - 重复适配器种子逻辑修复 - 失败转发日志 Direction: error **崩溃诊断** - crashlog 包 (Recover + WrapHTTP + LLMCall) - /api/v1/debug/goroutines 端点 - thinker 操作日志 + 30s stats - 日志写入 logs/ 目录持久化 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,38 +1,127 @@
|
||||
//go:build ignore
|
||||
|
||||
// gen_plugins generates plugins_gen.go from:
|
||||
// 1. plugins.json — built-in plugins (in cyrene-plugins)
|
||||
// 2. ../plugins/*/plugin.json — user plugins (auto-discovered)
|
||||
//
|
||||
// Usage: go run gen_plugins.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PluginEntry struct {
|
||||
Name string `json:"name"`
|
||||
Import string `json:"import"`
|
||||
Import string `json:"import,omitempty"` // built-in: explicit import
|
||||
Struct string `json:"struct"`
|
||||
Constructor string `json:"constructor,omitempty"`
|
||||
}
|
||||
|
||||
type PluginConfig struct {
|
||||
Version string `json:"version"`
|
||||
Plugins []PluginEntry `json:"plugins"`
|
||||
type PluginManifest struct {
|
||||
Name string `json:"name"`
|
||||
Struct string `json:"struct"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
// ── load built-in plugins from plugins.json ──
|
||||
|
||||
func loadBuiltinPlugins() ([]PluginEntry, error) {
|
||||
data, err := os.ReadFile("../plugins.json")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "read plugins.json: %v\n", err)
|
||||
os.Exit(1)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read plugins.json: %w", err)
|
||||
}
|
||||
var cfg struct {
|
||||
Version string `json:"version"`
|
||||
Plugins []PluginEntry `json:"plugins"`
|
||||
}
|
||||
var cfg PluginConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "parse plugins.json: %v\n", err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("parse plugins.json: %w", err)
|
||||
}
|
||||
return cfg.Plugins, nil
|
||||
}
|
||||
|
||||
// ── auto-discover user plugins from ../plugins/ ──
|
||||
|
||||
func discoverUserPlugins() ([]PluginEntry, error) {
|
||||
pluginsDir := filepath.Join("..", "..", "plugins")
|
||||
entries, err := os.ReadDir(pluginsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read plugins dir: %w", err)
|
||||
}
|
||||
|
||||
var result []PluginEntry
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(pluginsDir, e.Name())
|
||||
|
||||
// 必须有 plugin.json
|
||||
manifestPath := filepath.Join(dir, "plugin.json")
|
||||
manifestData, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var m PluginManifest
|
||||
if err := json.Unmarshal(manifestData, &m); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "⚠ skip %s: bad plugin.json: %v\n", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
if m.Name == "" {
|
||||
m.Name = e.Name()
|
||||
}
|
||||
|
||||
// 从 go.mod 读取模块路径作为 import
|
||||
modPath := filepath.Join(dir, "go.mod")
|
||||
modData, err := os.ReadFile(modPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "⚠ skip %s: no go.mod: %v\n", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
importPath := parseModule(modData)
|
||||
if importPath == "" {
|
||||
fmt.Fprintf(os.Stderr, "⚠ skip %s: cannot parse module from go.mod\n", e.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, PluginEntry{
|
||||
Name: m.Name,
|
||||
Import: importPath,
|
||||
Struct: m.Struct,
|
||||
})
|
||||
fmt.Printf(" ✓ discovered %s → %s\n", m.Name, importPath)
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseModule(data []byte) string {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "module ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, "module "))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── generate plugins_gen.go ──
|
||||
|
||||
func generate(plugins []PluginEntry) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("// Code generated by gen_plugins.go; DO NOT EDIT.\n\n")
|
||||
sb.WriteString("package main\n\n")
|
||||
@@ -40,19 +129,22 @@ func main() {
|
||||
sb.WriteString("\tplgSDK \"git.yeij.top/AskaEth/Cyrene-Plugins/sdk\"\n")
|
||||
|
||||
aliases := make(map[string]string)
|
||||
for _, p := range cfg.Plugins {
|
||||
alias := p.Name + "pkg"
|
||||
aliases[p.Name] = alias
|
||||
sb.WriteString(fmt.Sprintf("\t%s \"%s\"\n", alias, p.Import))
|
||||
imported := make(map[string]bool)
|
||||
for _, p := range plugins {
|
||||
pkgAlias := p.Name + "pkg"
|
||||
aliases[p.Name] = pkgAlias
|
||||
if !imported[p.Import] {
|
||||
sb.WriteString(fmt.Sprintf("\t%s \"%s\"\n", pkgAlias, p.Import))
|
||||
imported[p.Import] = true
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(")\n\n")
|
||||
sb.WriteString("func registerPlugins(registry interface{ Register(plgSDK.Tool) error }) {\n")
|
||||
|
||||
for _, p := range cfg.Plugins {
|
||||
for _, p := range plugins {
|
||||
alias := aliases[p.Name]
|
||||
if p.Constructor != "" {
|
||||
// Plugins with constructor (e.g. NewFilePlugin(dataDir))
|
||||
if p.Name == "file_ops" || p.Name == "http_request" {
|
||||
sb.WriteString(fmt.Sprintf("\tfor _, t := range %s.%s(nil).Tools() {\n", alias, p.Constructor))
|
||||
} else {
|
||||
@@ -69,8 +161,89 @@ func main() {
|
||||
|
||||
outPath := "plugins_gen.go"
|
||||
if err := os.WriteFile(outPath, []byte(sb.String()), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write %s: %v\n", outPath, err)
|
||||
return fmt.Errorf("write %s: %w", outPath, err)
|
||||
}
|
||||
fmt.Printf("Generated %s with %d plugins\n", outPath, len(plugins))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── auto-add require + replace directives to go.mod ──
|
||||
|
||||
func ensureGoMod(userPlugins []PluginEntry) error {
|
||||
modPath := filepath.Join("..", "go.mod")
|
||||
data, err := os.ReadFile(modPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read go.mod: %w", err)
|
||||
}
|
||||
content := string(data)
|
||||
pluginsDir := filepath.Join("..", "..", "plugins")
|
||||
entries, _ := os.ReadDir(pluginsDir)
|
||||
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() { continue }
|
||||
dir := filepath.Join(pluginsDir, e.Name())
|
||||
modData, err := os.ReadFile(filepath.Join(dir, "go.mod"))
|
||||
if err != nil { continue }
|
||||
mod := parseModule(modData)
|
||||
if mod == "" { continue }
|
||||
|
||||
relPath, _ := filepath.Rel(filepath.Join(".."), dir)
|
||||
relPath = strings.ReplaceAll(relPath, "\\", "/")
|
||||
|
||||
// ensure require
|
||||
reqLine := fmt.Sprintf("\t%s v0.0.0\n", mod)
|
||||
if !strings.Contains(content, reqLine) {
|
||||
reqBlock := strings.Index(content, "require (")
|
||||
if reqBlock < 0 { continue }
|
||||
closeIdx := strings.Index(content[reqBlock:], ")")
|
||||
if closeIdx < 0 { continue }
|
||||
insertAt := reqBlock + closeIdx
|
||||
content = content[:insertAt] + reqLine + content[insertAt:]
|
||||
}
|
||||
|
||||
// ensure replace
|
||||
replaceLine := fmt.Sprintf("\t%s => %s\n", mod, relPath)
|
||||
if !strings.Contains(content, replaceLine) {
|
||||
replaceBlock := strings.Index(content, "replace (")
|
||||
if replaceBlock < 0 {
|
||||
content += fmt.Sprintf("\nreplace (\n%s)\n", replaceLine)
|
||||
} else {
|
||||
closeIdx := strings.Index(content[replaceBlock:], ")")
|
||||
if closeIdx < 0 { continue }
|
||||
insertAt := replaceBlock + closeIdx
|
||||
content = content[:insertAt] + replaceLine + content[insertAt:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(modPath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
func main() {
|
||||
builtins, err := loadBuiltinPlugins()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "load builtins: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Built-in plugins: %d\n", len(builtins))
|
||||
|
||||
fmt.Println("Discovering user plugins...")
|
||||
userPlugins, err := discoverUserPlugins()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "discover user plugins: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 自动补 go.mod replace 指令
|
||||
if len(userPlugins) > 0 {
|
||||
if err := ensureGoMod(userPlugins); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ensure go.mod: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
all := append(builtins, userPlugins...)
|
||||
if err := generate(all); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "generate: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Generated %s with %d plugins\n", outPath, len(cfg.Plugins))
|
||||
}
|
||||
|
||||
+124
-9
@@ -9,6 +9,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/background"
|
||||
aiConfig "git.yeij.top/AskaEth/Cyrene/ai-core/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/crashlog"
|
||||
ctxbuild "git.yeij.top/AskaEth/Cyrene/ai-core/internal/context"
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/host"
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm"
|
||||
@@ -448,15 +450,78 @@ func main() {
|
||||
)
|
||||
orch.SetToolRegistry(toolRegistry)
|
||||
// 设置工具结果主动推送回调 — 通用,不绑定特定工具
|
||||
orch.SetToolResultPusher(func(sessionID, userID, toolName, result string) {
|
||||
if thinker == nil {
|
||||
orch.SetToolResultPusher(func(sessionID, userID, toolName, result string, params orchestrator.SynthesizeParams) {
|
||||
if thinker == nil || orch == nil {
|
||||
return
|
||||
}
|
||||
// 通过 thinker 的主动消息机制推送
|
||||
if userID == "" {
|
||||
userID = adminUserID
|
||||
}
|
||||
thinker.TriggerReminderMessage(userID, sessionID, fmt.Sprintf("🔧 %s 执行完成:%s", toolName, result))
|
||||
|
||||
// 异步执行跟进:触发 LLM 生成回复并推送到原消息渠道
|
||||
go func() {
|
||||
var toolResult map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result), &toolResult); err != nil {
|
||||
toolResult = map[string]interface{}{"output": result}
|
||||
}
|
||||
output, _ := toolResult["output"].(string)
|
||||
if output == "" {
|
||||
output = result
|
||||
}
|
||||
|
||||
followUpMsg := fmt.Sprintf("【系统消息】后台工具 %s 执行完成。结果:\n%s\n\n请基于以上结果生成回复发送给用户。", toolName, output)
|
||||
|
||||
// 持久化工具结果到会话历史(重启后不丢失)
|
||||
ctxBuilder.CacheMessage(sessionID, model.RoleSystem,
|
||||
fmt.Sprintf("[工具 %s 执行结果]\n%s", toolName, output))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
eventCh, err := orch.ProcessInput(ctx, orchestrator.ProcessParams{
|
||||
UserID: userID,
|
||||
SessionID: sessionID,
|
||||
Message: followUpMsg,
|
||||
Mode: "text",
|
||||
Nickname: params.Nickname,
|
||||
ChannelType: params.ChannelType,
|
||||
ChannelID: params.ChannelID,
|
||||
AdapterName: params.AdapterName,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[tool-followup] ProcessInput 失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for event := range eventCh {
|
||||
if event.Type == model.StreamDelta {
|
||||
sb.WriteString(event.Delta)
|
||||
}
|
||||
}
|
||||
followUpResponse := sb.String()
|
||||
if followUpResponse == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 推送到原平台渠道
|
||||
if params.ChannelType != "" && params.ChannelID != "" && params.AdapterName != "" {
|
||||
target := background.ProactiveTarget{
|
||||
Platform: params.AdapterName,
|
||||
ChatType: "private", // send-proactive 要求 private/group,不是 direct
|
||||
}
|
||||
if params.ChannelType == "group" {
|
||||
target.ChatType = "group"
|
||||
target.GroupID = params.ChannelID
|
||||
} else {
|
||||
target.UserID = strings.TrimPrefix(params.ChannelID, "private_")
|
||||
}
|
||||
log.Printf("[tool-followup] 推送跟进到 platform=%s chat=%s channel=%s len=%d",
|
||||
target.Platform, target.ChatType, params.ChannelID, len(followUpResponse))
|
||||
thinker.PushPlatformMessage(target, followUpResponse)
|
||||
} else {
|
||||
thinker.TriggerReminderMessage(userID, sessionID, followUpResponse)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
if visionProvider != nil {
|
||||
@@ -593,6 +658,21 @@ func main() {
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/v1/debug/lock-holder", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
by, at := thinker.LockHolder()
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"locked_by": by,
|
||||
"locked_at": at.Format(time.RFC3339),
|
||||
"held_for": time.Since(at).String(),
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/api/v1/debug/goroutines", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
buf := make([]byte, 1024*1024)
|
||||
n := runtime.Stack(buf, true)
|
||||
w.Write(buf[:n])
|
||||
})
|
||||
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok","service":"ai-core","model":"` + chatAdapter.ModelName() + `"}`))
|
||||
@@ -622,6 +702,7 @@ func main() {
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
@@ -714,10 +795,20 @@ func main() {
|
||||
json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
|
||||
// 启动HTTP服务
|
||||
// Debug: 全链路 HTTP 请求日志
|
||||
debugMux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
crashlog.WrapHTTP(mux).ServeHTTP(w, r)
|
||||
elapsed := time.Since(start)
|
||||
if elapsed > 2*time.Second || r.URL.Path != "/api/v1/health" {
|
||||
log.Printf("[http] %s %s %v", r.Method, r.URL.Path, elapsed.Round(time.Millisecond))
|
||||
}
|
||||
})
|
||||
|
||||
// 启动HTTP服务(全局 panic 恢复 + 崩溃日志)
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: mux,
|
||||
Handler: debugMux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -727,6 +818,21 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Debug: 每30秒输出内存和goroutine统计
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
var m runtime.MemStats
|
||||
for range ticker.C {
|
||||
runtime.ReadMemStats(&m)
|
||||
log.Printf("[stats] goroutines=%d heap=%dMB sys=%dMB gc=%d",
|
||||
runtime.NumGoroutine(),
|
||||
m.HeapAlloc/1024/1024,
|
||||
m.Sys/1024/1024,
|
||||
m.NumGC)
|
||||
}
|
||||
}()
|
||||
|
||||
// 优雅关闭
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
@@ -1013,7 +1119,8 @@ func handleChat(
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Inject admin flag for tool access control.
|
||||
ctx = context.WithValue(ctx, plgManager.CtxKeyIsAdmin, req.IsAdmin)
|
||||
@@ -1035,7 +1142,7 @@ func handleChat(
|
||||
|
||||
// Admin private messages: redirect to the main admin session so conversation
|
||||
// history is shared across platforms (OBv11, web UI, etc.).
|
||||
if req.UserID == "admin" && req.Source.ChannelType == "direct" && adminSessionID != "" {
|
||||
if req.IsAdmin && req.Source.ChannelType == "direct" && adminSessionID != "" {
|
||||
req.SessionID = adminSessionID
|
||||
}
|
||||
|
||||
@@ -1051,6 +1158,9 @@ func handleChat(
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
// Trace: message received
|
||||
AddTraceEvent("msg_received", req.SessionID, req.UserID, "收到消息: "+req.Message, "success", fmt.Sprintf("platform=%s", req.Source.Platform), 0, nil)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
|
||||
@@ -1058,7 +1168,12 @@ func handleChat(
|
||||
}
|
||||
|
||||
// 1.5 缓存用户消息到会话历史(在 Orchestrator 之前,确保顺序正确:user → assistant)
|
||||
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, req.Message)
|
||||
// 管理员主会话聚合多平台消息,加平台标签让 LLM 知道消息来源
|
||||
userMsgForCache := req.Message
|
||||
if req.IsAdmin && req.SessionID == adminSessionID && req.Source.AdapterName != "" {
|
||||
userMsgForCache = fmt.Sprintf("[来自 %s] %s", req.Source.AdapterName, req.Message)
|
||||
}
|
||||
ctxBuilder.CacheMessage(req.SessionID, model.RoleUser, userMsgForCache)
|
||||
|
||||
// 2. 调用 Orchestrator 处理(替代原有的线性处理流程)
|
||||
// Orchestrator 内部处理:意图分析 → 子会话分派 → 结果汇总 → 综合生成回复
|
||||
|
||||
@@ -58,7 +58,7 @@ func GetTraceEvents(sessionID string, limit int) []TraceEvent {
|
||||
if limit <= 0 || limit > len(traceEvents) {
|
||||
limit = len(traceEvents)
|
||||
}
|
||||
var result []TraceEvent
|
||||
result := make([]TraceEvent, 0)
|
||||
// Return newest first.
|
||||
for i := len(traceEvents) - 1; i >= 0 && len(result) < limit; i-- {
|
||||
ev := traceEvents[i]
|
||||
|
||||
Reference in New Issue
Block a user