mirror of
https://github.com/ccfos/nightingale.git
synced 2026-03-13 19:38:59 +00:00
Compare commits
1 Commits
aiagent
...
event-deta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b493b1ea9d |
3338
aiagent/ai_agent.go
3338
aiagent/ai_agent.go
File diff suppressed because it is too large
Load Diff
@@ -1,546 +0,0 @@
|
||||
package aiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/datasource"
|
||||
"github.com/ccfos/nightingale/v6/dscache"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/prom"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// ToolTypeBuiltin 内置工具类型
|
||||
ToolTypeBuiltin = "builtin"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// 数据源获取函数(支持注入,便于测试)
|
||||
// =============================================================================
|
||||
|
||||
// PromClientGetter Prometheus 客户端获取函数类型
|
||||
type PromClientGetter func(dsId int64) prom.API
|
||||
|
||||
// SQLDatasourceGetter SQL 数据源获取函数类型
|
||||
type SQLDatasourceGetter func(dsType string, dsId int64) (datasource.Datasource, bool)
|
||||
|
||||
// 默认使用 GlobalCache,可通过 SetPromClientGetter/SetSQLDatasourceGetter 替换
|
||||
var (
|
||||
getPromClientFunc PromClientGetter = defaultGetPromClient
|
||||
getSQLDatasourceFunc SQLDatasourceGetter = defaultGetSQLDatasource
|
||||
)
|
||||
|
||||
// SetPromClientGetter 设置 Prometheus 客户端获取函数(用于测试)
|
||||
func SetPromClientGetter(getter PromClientGetter) {
|
||||
getPromClientFunc = getter
|
||||
}
|
||||
|
||||
// SetSQLDatasourceGetter 设置 SQL 数据源获取函数(用于测试)
|
||||
func SetSQLDatasourceGetter(getter SQLDatasourceGetter) {
|
||||
getSQLDatasourceFunc = getter
|
||||
}
|
||||
|
||||
// ResetDatasourceGetters 重置为默认的数据源获取函数
|
||||
func ResetDatasourceGetters() {
|
||||
getPromClientFunc = defaultGetPromClient
|
||||
getSQLDatasourceFunc = defaultGetSQLDatasource
|
||||
}
|
||||
|
||||
func defaultGetPromClient(dsId int64) prom.API {
|
||||
// Default: no PromClient available. Use SetPromClientGetter to inject.
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultGetSQLDatasource(dsType string, dsId int64) (datasource.Datasource, bool) {
|
||||
return dscache.DsCache.Get(dsType, dsId)
|
||||
}
|
||||
|
||||
// BuiltinToolHandler 内置工具处理函数
|
||||
type BuiltinToolHandler func(ctx context.Context, wfCtx *models.WorkflowContext, args map[string]interface{}) (string, error)
|
||||
|
||||
// BuiltinTool 内置工具定义
|
||||
type BuiltinTool struct {
|
||||
Definition AgentTool
|
||||
Handler BuiltinToolHandler
|
||||
}
|
||||
|
||||
// builtinTools 内置工具注册表
|
||||
var builtinTools = map[string]*BuiltinTool{
|
||||
// Prometheus 相关工具
|
||||
"list_metrics": {
|
||||
Definition: AgentTool{
|
||||
Name: "list_metrics",
|
||||
Description: "搜索 Prometheus 数据源的指标名称,支持关键词模糊匹配",
|
||||
Type: ToolTypeBuiltin,
|
||||
Parameters: []ToolParameter{
|
||||
{Name: "keyword", Type: "string", Description: "搜索关键词,模糊匹配指标名", Required: false},
|
||||
{Name: "limit", Type: "integer", Description: "返回数量限制,默认30", Required: false},
|
||||
},
|
||||
},
|
||||
Handler: listMetrics,
|
||||
},
|
||||
"get_metric_labels": {
|
||||
Definition: AgentTool{
|
||||
Name: "get_metric_labels",
|
||||
Description: "获取 Prometheus 指标的所有标签键及其可选值",
|
||||
Type: ToolTypeBuiltin,
|
||||
Parameters: []ToolParameter{
|
||||
{Name: "metric", Type: "string", Description: "指标名称", Required: true},
|
||||
},
|
||||
},
|
||||
Handler: getMetricLabels,
|
||||
},
|
||||
|
||||
// SQL 类数据源相关工具
|
||||
"list_databases": {
|
||||
Definition: AgentTool{
|
||||
Name: "list_databases",
|
||||
Description: "列出 SQL 数据源(MySQL/Doris/ClickHouse/PostgreSQL)中的所有数据库",
|
||||
Type: ToolTypeBuiltin,
|
||||
Parameters: []ToolParameter{},
|
||||
},
|
||||
Handler: listDatabases,
|
||||
},
|
||||
"list_tables": {
|
||||
Definition: AgentTool{
|
||||
Name: "list_tables",
|
||||
Description: "列出指定数据库中的所有表",
|
||||
Type: ToolTypeBuiltin,
|
||||
Parameters: []ToolParameter{
|
||||
{Name: "database", Type: "string", Description: "数据库名", Required: true},
|
||||
},
|
||||
},
|
||||
Handler: listTables,
|
||||
},
|
||||
"describe_table": {
|
||||
Definition: AgentTool{
|
||||
Name: "describe_table",
|
||||
Description: "获取表的字段结构(字段名、类型、注释)",
|
||||
Type: ToolTypeBuiltin,
|
||||
Parameters: []ToolParameter{
|
||||
{Name: "database", Type: "string", Description: "数据库名", Required: true},
|
||||
{Name: "table", Type: "string", Description: "表名", Required: true},
|
||||
},
|
||||
},
|
||||
Handler: describeTable,
|
||||
},
|
||||
}
|
||||
|
||||
// GetBuiltinToolDef 获取内置工具定义
|
||||
func GetBuiltinToolDef(name string) (AgentTool, bool) {
|
||||
if tool, ok := builtinTools[name]; ok {
|
||||
return tool.Definition, true
|
||||
}
|
||||
return AgentTool{}, false
|
||||
}
|
||||
|
||||
// GetBuiltinToolDefs 获取指定的内置工具定义列表
|
||||
func GetBuiltinToolDefs(names []string) []AgentTool {
|
||||
var defs []AgentTool
|
||||
for _, name := range names {
|
||||
if def, ok := GetBuiltinToolDef(name); ok {
|
||||
defs = append(defs, def)
|
||||
}
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// GetAllBuiltinToolDefs 获取所有内置工具定义
|
||||
func GetAllBuiltinToolDefs() []AgentTool {
|
||||
defs := make([]AgentTool, 0, len(builtinTools))
|
||||
for _, tool := range builtinTools {
|
||||
defs = append(defs, tool.Definition)
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// ExecuteBuiltinTool 执行内置工具
|
||||
// 返回值:result, handled, error
|
||||
// handled 表示是否是内置工具(true 表示已处理,false 表示不是内置工具需要继续查找)
|
||||
func ExecuteBuiltinTool(ctx context.Context, name string, wfCtx *models.WorkflowContext, argsJSON string) (string, bool, error) {
|
||||
tool, exists := builtinTools[name]
|
||||
if !exists {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// 解析参数
|
||||
var args map[string]interface{}
|
||||
if argsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
// 如果不是 JSON,尝试作为简单字符串参数
|
||||
args = map[string]interface{}{"input": argsJSON}
|
||||
}
|
||||
}
|
||||
if args == nil {
|
||||
args = make(map[string]interface{})
|
||||
}
|
||||
|
||||
result, err := tool.Handler(ctx, wfCtx, args)
|
||||
return result, true, err
|
||||
}
|
||||
|
||||
// getDatasourceId 从 wfCtx.Inputs 中获取 datasource_id
|
||||
func getDatasourceId(wfCtx *models.WorkflowContext) int64 {
|
||||
if wfCtx == nil || wfCtx.Inputs == nil {
|
||||
return 0
|
||||
}
|
||||
var dsId int64
|
||||
if dsIdStr, ok := wfCtx.Inputs["datasource_id"]; ok {
|
||||
fmt.Sscanf(dsIdStr, "%d", &dsId)
|
||||
}
|
||||
return dsId
|
||||
}
|
||||
|
||||
// getDatasourceType 从 wfCtx.Inputs 中获取 datasource_type
|
||||
func getDatasourceType(wfCtx *models.WorkflowContext) string {
|
||||
if wfCtx == nil || wfCtx.Inputs == nil {
|
||||
return ""
|
||||
}
|
||||
return wfCtx.Inputs["datasource_type"]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Prometheus 工具实现
|
||||
// =============================================================================
|
||||
|
||||
// listMetrics 列出 Prometheus 指标
|
||||
func listMetrics(ctx context.Context, wfCtx *models.WorkflowContext, args map[string]interface{}) (string, error) {
|
||||
dsId := getDatasourceId(wfCtx)
|
||||
if dsId == 0 {
|
||||
return "", fmt.Errorf("datasource_id not found in inputs")
|
||||
}
|
||||
|
||||
keyword, _ := args["keyword"].(string)
|
||||
limit := 30
|
||||
if l, ok := args["limit"].(float64); ok && l > 0 {
|
||||
limit = int(l)
|
||||
}
|
||||
|
||||
// 获取 Prometheus 客户端
|
||||
client := getPromClientFunc(dsId)
|
||||
if client == nil {
|
||||
return "", fmt.Errorf("prometheus datasource not found: %d", dsId)
|
||||
}
|
||||
|
||||
// 调用 LabelValues 获取 __name__ 的所有值(即所有指标名)
|
||||
values, _, err := client.LabelValues(ctx, "__name__", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get metrics: %v", err)
|
||||
}
|
||||
|
||||
// 过滤和限制
|
||||
result := make([]string, 0)
|
||||
keyword = strings.ToLower(keyword)
|
||||
for _, v := range values {
|
||||
m := string(v)
|
||||
if keyword == "" || strings.Contains(strings.ToLower(m), keyword) {
|
||||
result = append(result, m)
|
||||
if len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debugf("list_metrics: found %d metrics (keyword=%s, limit=%d)", len(result), keyword, limit)
|
||||
|
||||
bytes, _ := json.Marshal(result)
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// getMetricLabels 获取指标的标签
|
||||
func getMetricLabels(ctx context.Context, wfCtx *models.WorkflowContext, args map[string]interface{}) (string, error) {
|
||||
dsId := getDatasourceId(wfCtx)
|
||||
if dsId == 0 {
|
||||
return "", fmt.Errorf("datasource_id not found in inputs")
|
||||
}
|
||||
|
||||
metric, ok := args["metric"].(string)
|
||||
if !ok || metric == "" {
|
||||
return "", fmt.Errorf("metric parameter is required")
|
||||
}
|
||||
|
||||
client := getPromClientFunc(dsId)
|
||||
if client == nil {
|
||||
return "", fmt.Errorf("prometheus datasource not found: %d", dsId)
|
||||
}
|
||||
|
||||
// 使用 Series 接口获取指标的所有 series
|
||||
endTime := time.Now()
|
||||
startTime := endTime.Add(-1 * time.Hour)
|
||||
series, _, err := client.Series(ctx, []string{metric}, startTime, endTime)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get metric series: %v", err)
|
||||
}
|
||||
|
||||
// 聚合标签键值
|
||||
labels := make(map[string][]string)
|
||||
seen := make(map[string]map[string]bool)
|
||||
|
||||
for _, s := range series {
|
||||
for k, v := range s {
|
||||
key := string(k)
|
||||
val := string(v)
|
||||
if key == "__name__" {
|
||||
continue
|
||||
}
|
||||
if seen[key] == nil {
|
||||
seen[key] = make(map[string]bool)
|
||||
}
|
||||
if !seen[key][val] {
|
||||
seen[key][val] = true
|
||||
labels[key] = append(labels[key], val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debugf("get_metric_labels: metric=%s, found %d labels", metric, len(labels))
|
||||
|
||||
bytes, _ := json.Marshal(labels)
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SQL 数据源工具实现
|
||||
// =============================================================================
|
||||
|
||||
// SQLMetadataQuerier SQL 元数据查询接口
|
||||
type SQLMetadataQuerier interface {
|
||||
ListDatabases(ctx context.Context) ([]string, error)
|
||||
ListTables(ctx context.Context, database string) ([]string, error)
|
||||
DescribeTable(ctx context.Context, database, table string) ([]map[string]interface{}, error)
|
||||
}
|
||||
|
||||
// listDatabases 列出数据库
|
||||
func listDatabases(ctx context.Context, wfCtx *models.WorkflowContext, args map[string]interface{}) (string, error) {
|
||||
dsId := getDatasourceId(wfCtx)
|
||||
dsType := getDatasourceType(wfCtx)
|
||||
if dsId == 0 {
|
||||
return "", fmt.Errorf("datasource_id not found in inputs")
|
||||
}
|
||||
if dsType == "" {
|
||||
return "", fmt.Errorf("datasource_type not found in inputs")
|
||||
}
|
||||
|
||||
plug, exists := getSQLDatasourceFunc(dsType, dsId)
|
||||
if !exists {
|
||||
return "", fmt.Errorf("datasource not found: %s/%d", dsType, dsId)
|
||||
}
|
||||
|
||||
// 构建查询 SQL
|
||||
var sql string
|
||||
switch dsType {
|
||||
case "mysql", "doris":
|
||||
sql = "SHOW DATABASES"
|
||||
case "ck", "clickhouse":
|
||||
sql = "SHOW DATABASES"
|
||||
case "pgsql", "postgresql":
|
||||
sql = "SELECT datname FROM pg_database WHERE datistemplate = false"
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported datasource type for list_databases: %s", dsType)
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
query := map[string]interface{}{"sql": sql}
|
||||
data, _, err := plug.QueryLog(ctx, query)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list databases: %v", err)
|
||||
}
|
||||
|
||||
// 提取数据库名
|
||||
databases := extractColumnValues(data, dsType, "database")
|
||||
|
||||
logger.Debugf("list_databases: dsType=%s, found %d databases", dsType, len(databases))
|
||||
|
||||
bytes, _ := json.Marshal(databases)
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// listTables 列出表
|
||||
func listTables(ctx context.Context, wfCtx *models.WorkflowContext, args map[string]interface{}) (string, error) {
|
||||
dsId := getDatasourceId(wfCtx)
|
||||
dsType := getDatasourceType(wfCtx)
|
||||
if dsId == 0 {
|
||||
return "", fmt.Errorf("datasource_id not found in inputs")
|
||||
}
|
||||
|
||||
database, ok := args["database"].(string)
|
||||
if !ok || database == "" {
|
||||
return "", fmt.Errorf("database parameter is required")
|
||||
}
|
||||
|
||||
plug, exists := getSQLDatasourceFunc(dsType, dsId)
|
||||
if !exists {
|
||||
return "", fmt.Errorf("datasource not found: %s/%d", dsType, dsId)
|
||||
}
|
||||
|
||||
// 构建查询 SQL
|
||||
var sql string
|
||||
switch dsType {
|
||||
case "mysql", "doris":
|
||||
sql = fmt.Sprintf("SHOW TABLES FROM `%s`", database)
|
||||
case "ck", "clickhouse":
|
||||
sql = fmt.Sprintf("SHOW TABLES FROM `%s`", database)
|
||||
case "pgsql", "postgresql":
|
||||
sql = fmt.Sprintf("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported datasource type for list_tables: %s", dsType)
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
query := map[string]interface{}{"sql": sql, "database": database}
|
||||
data, _, err := plug.QueryLog(ctx, query)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list tables: %v", err)
|
||||
}
|
||||
|
||||
// 提取表名
|
||||
tables := extractColumnValues(data, dsType, "table")
|
||||
|
||||
logger.Debugf("list_tables: dsType=%s, database=%s, found %d tables", dsType, database, len(tables))
|
||||
|
||||
bytes, _ := json.Marshal(tables)
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// describeTable 获取表结构
|
||||
func describeTable(ctx context.Context, wfCtx *models.WorkflowContext, args map[string]interface{}) (string, error) {
|
||||
dsId := getDatasourceId(wfCtx)
|
||||
dsType := getDatasourceType(wfCtx)
|
||||
if dsId == 0 {
|
||||
return "", fmt.Errorf("datasource_id not found in inputs")
|
||||
}
|
||||
|
||||
database, ok := args["database"].(string)
|
||||
if !ok || database == "" {
|
||||
return "", fmt.Errorf("database parameter is required")
|
||||
}
|
||||
table, ok := args["table"].(string)
|
||||
if !ok || table == "" {
|
||||
return "", fmt.Errorf("table parameter is required")
|
||||
}
|
||||
|
||||
plug, exists := getSQLDatasourceFunc(dsType, dsId)
|
||||
if !exists {
|
||||
return "", fmt.Errorf("datasource not found: %s/%d", dsType, dsId)
|
||||
}
|
||||
|
||||
// 构建查询 SQL
|
||||
var sql string
|
||||
switch dsType {
|
||||
case "mysql", "doris":
|
||||
sql = fmt.Sprintf("DESCRIBE `%s`.`%s`", database, table)
|
||||
case "ck", "clickhouse":
|
||||
sql = fmt.Sprintf("DESCRIBE TABLE `%s`.`%s`", database, table)
|
||||
case "pgsql", "postgresql":
|
||||
sql = fmt.Sprintf(`SELECT column_name as "Field", data_type as "Type", is_nullable as "Null", column_default as "Default" FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '%s'`, table)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported datasource type for describe_table: %s", dsType)
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
query := map[string]interface{}{"sql": sql, "database": database}
|
||||
data, _, err := plug.QueryLog(ctx, query)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to describe table: %v", err)
|
||||
}
|
||||
|
||||
// 转换为统一的列结构
|
||||
columns := convertToColumnInfo(data, dsType)
|
||||
|
||||
logger.Debugf("describe_table: dsType=%s, table=%s.%s, found %d columns", dsType, database, table, len(columns))
|
||||
|
||||
bytes, _ := json.Marshal(columns)
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// ColumnInfo 列信息
|
||||
type ColumnInfo struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// extractColumnValues 从查询结果中提取列值
|
||||
func extractColumnValues(data []interface{}, dsType string, columnType string) []string {
|
||||
result := make([]string, 0)
|
||||
for _, row := range data {
|
||||
if rowMap, ok := row.(map[string]interface{}); ok {
|
||||
// 尝试多种可能的列名
|
||||
var value string
|
||||
for _, key := range getPossibleColumnNames(dsType, columnType) {
|
||||
if v, ok := rowMap[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
value = s
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if value != "" {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getPossibleColumnNames 获取可能的列名
|
||||
func getPossibleColumnNames(dsType string, columnType string) []string {
|
||||
switch columnType {
|
||||
case "database":
|
||||
return []string{"Database", "database", "datname", "name"}
|
||||
case "table":
|
||||
return []string{"Tables_in_", "table", "tablename", "name", "Name"}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// convertToColumnInfo 将查询结果转换为统一的列信息格式
|
||||
func convertToColumnInfo(data []interface{}, dsType string) []ColumnInfo {
|
||||
result := make([]ColumnInfo, 0)
|
||||
for _, row := range data {
|
||||
if rowMap, ok := row.(map[string]interface{}); ok {
|
||||
col := ColumnInfo{}
|
||||
|
||||
// 提取列名
|
||||
for _, key := range []string{"Field", "field", "column_name", "name"} {
|
||||
if v, ok := rowMap[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
col.Name = s
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提取类型
|
||||
for _, key := range []string{"Type", "type", "data_type"} {
|
||||
if v, ok := rowMap[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
col.Type = s
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提取注释(可选)
|
||||
for _, key := range []string{"Comment", "comment", "column_comment"} {
|
||||
if v, ok := rowMap[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
col.Comment = s
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if col.Name != "" {
|
||||
result = append(result, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultClaudeURL = "https://api.anthropic.com/v1/messages"
|
||||
ClaudeAPIVersion = "2023-06-01"
|
||||
DefaultClaudeMaxTokens = 4096
|
||||
)
|
||||
|
||||
// Claude implements the LLM interface for Anthropic Claude API
|
||||
type Claude struct {
|
||||
config *Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewClaude creates a new Claude provider
|
||||
func NewClaude(cfg *Config, client *http.Client) (*Claude, error) {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = DefaultClaudeURL
|
||||
}
|
||||
return &Claude{
|
||||
config: cfg,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Claude) Name() string {
|
||||
return ProviderClaude
|
||||
}
|
||||
|
||||
// Claude API request/response structures
|
||||
type claudeRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []claudeMessage `json:"messages"`
|
||||
System string `json:"system,omitempty"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
Stop []string `json:"stop_sequences,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []claudeTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type claudeMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content []claudeContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
type claudeContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type claudeTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]interface{} `json:"input_schema"`
|
||||
}
|
||||
|
||||
type claudeResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []claudeContentBlock `json:"content"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
StopSequence string `json:"stop_sequence,omitempty"`
|
||||
Usage *struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage,omitempty"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Claude streaming event types
|
||||
type claudeStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Index int `json:"index,omitempty"`
|
||||
ContentBlock *claudeContentBlock `json:"content_block,omitempty"`
|
||||
Delta *claudeStreamDelta `json:"delta,omitempty"`
|
||||
Message *claudeResponse `json:"message,omitempty"`
|
||||
Usage *claudeStreamUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type claudeStreamDelta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
PartialJSON string `json:"partial_json,omitempty"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
}
|
||||
|
||||
type claudeStreamUsage struct {
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
func (c *Claude) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {
|
||||
claudeReq := c.convertRequest(req)
|
||||
claudeReq.Stream = false
|
||||
|
||||
respBody, err := c.doRequest(ctx, claudeReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var claudeResp claudeResponse
|
||||
if err := json.Unmarshal(respBody, &claudeResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if claudeResp.Error != nil {
|
||||
return nil, fmt.Errorf("Claude API error: %s", claudeResp.Error.Message)
|
||||
}
|
||||
|
||||
return c.convertResponse(&claudeResp), nil
|
||||
}
|
||||
|
||||
func (c *Claude) GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error) {
|
||||
claudeReq := c.convertRequest(req)
|
||||
claudeReq.Stream = true
|
||||
|
||||
jsonData, err := json.Marshal(claudeReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.config.BaseURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
c.setHeaders(httpReq)
|
||||
|
||||
resp, err := c.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("Claude API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
ch := make(chan StreamChunk, 100)
|
||||
go c.streamResponse(ctx, resp, ch)
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (c *Claude) streamResponse(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {
|
||||
defer close(ch)
|
||||
defer resp.Body.Close()
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
var currentToolCall *ToolCall
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ch <- StreamChunk{Done: true, Error: ctx.Err()}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
ch <- StreamChunk{Done: true, Error: err}
|
||||
} else {
|
||||
ch <- StreamChunk{Done: true}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var event claudeStreamEvent
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "content_block_start":
|
||||
if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" {
|
||||
currentToolCall = &ToolCall{
|
||||
ID: event.ContentBlock.ID,
|
||||
Name: event.ContentBlock.Name,
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
if event.Delta != nil {
|
||||
chunk := StreamChunk{}
|
||||
|
||||
switch event.Delta.Type {
|
||||
case "text_delta":
|
||||
chunk.Content = event.Delta.Text
|
||||
case "input_json_delta":
|
||||
if currentToolCall != nil {
|
||||
currentToolCall.Arguments += event.Delta.PartialJSON
|
||||
}
|
||||
}
|
||||
|
||||
if chunk.Content != "" {
|
||||
ch <- chunk
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_stop":
|
||||
if currentToolCall != nil {
|
||||
ch <- StreamChunk{
|
||||
ToolCalls: []ToolCall{*currentToolCall},
|
||||
}
|
||||
currentToolCall = nil
|
||||
}
|
||||
|
||||
case "message_delta":
|
||||
if event.Delta != nil && event.Delta.StopReason != "" {
|
||||
ch <- StreamChunk{
|
||||
FinishReason: event.Delta.StopReason,
|
||||
}
|
||||
}
|
||||
|
||||
case "message_stop":
|
||||
ch <- StreamChunk{Done: true}
|
||||
return
|
||||
|
||||
case "error":
|
||||
ch <- StreamChunk{Done: true, Error: fmt.Errorf("stream error")}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Claude) convertRequest(req *GenerateRequest) *claudeRequest {
|
||||
claudeReq := &claudeRequest{
|
||||
Model: c.config.Model,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
Stop: req.Stop,
|
||||
}
|
||||
|
||||
if claudeReq.MaxTokens <= 0 {
|
||||
claudeReq.MaxTokens = DefaultClaudeMaxTokens
|
||||
}
|
||||
|
||||
// Extract system message and convert other messages
|
||||
for _, msg := range req.Messages {
|
||||
if msg.Role == RoleSystem {
|
||||
claudeReq.System = msg.Content
|
||||
continue
|
||||
}
|
||||
|
||||
// Claude uses content blocks instead of plain strings
|
||||
claudeMsg := claudeMessage{
|
||||
Role: msg.Role,
|
||||
Content: []claudeContentBlock{
|
||||
{Type: "text", Text: msg.Content},
|
||||
},
|
||||
}
|
||||
claudeReq.Messages = append(claudeReq.Messages, claudeMsg)
|
||||
}
|
||||
|
||||
// Convert tools
|
||||
for _, tool := range req.Tools {
|
||||
claudeReq.Tools = append(claudeReq.Tools, claudeTool{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
InputSchema: tool.Parameters,
|
||||
})
|
||||
}
|
||||
|
||||
return claudeReq
|
||||
}
|
||||
|
||||
func (c *Claude) convertResponse(resp *claudeResponse) *GenerateResponse {
|
||||
result := &GenerateResponse{
|
||||
FinishReason: resp.StopReason,
|
||||
}
|
||||
|
||||
// Extract text content and tool calls
|
||||
var textParts []string
|
||||
for _, block := range resp.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
textParts = append(textParts, block.Text)
|
||||
case "tool_use":
|
||||
inputJSON, _ := json.Marshal(block.Input)
|
||||
result.ToolCalls = append(result.ToolCalls, ToolCall{
|
||||
ID: block.ID,
|
||||
Name: block.Name,
|
||||
Arguments: string(inputJSON),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
result.Content = strings.Join(textParts, "")
|
||||
|
||||
if resp.Usage != nil {
|
||||
result.Usage = &Usage{
|
||||
PromptTokens: resp.Usage.InputTokens,
|
||||
CompletionTokens: resp.Usage.OutputTokens,
|
||||
TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *Claude) doRequest(ctx context.Context, req *claudeRequest) ([]byte, error) {
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.config.BaseURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
c.setHeaders(httpReq)
|
||||
|
||||
resp, err := c.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("Claude API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *Claude) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("anthropic-version", ClaudeAPIVersion)
|
||||
|
||||
if c.config.APIKey != "" {
|
||||
req.Header.Set("x-api-key", c.config.APIKey)
|
||||
}
|
||||
|
||||
for k, v := range c.config.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultGeminiURL = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
)
|
||||
|
||||
// Gemini implements the LLM interface for Google Gemini API
|
||||
type Gemini struct {
|
||||
config *Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewGemini creates a new Gemini provider
|
||||
func NewGemini(cfg *Config, client *http.Client) (*Gemini, error) {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = DefaultGeminiURL
|
||||
}
|
||||
return &Gemini{
|
||||
config: cfg,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *Gemini) Name() string {
|
||||
return ProviderGemini
|
||||
}
|
||||
|
||||
// Gemini API request/response structures
|
||||
type geminiRequest struct {
|
||||
Contents []geminiContent `json:"contents"`
|
||||
SystemInstruction *geminiContent `json:"systemInstruction,omitempty"`
|
||||
Tools []geminiTool `json:"tools,omitempty"`
|
||||
GenerationConfig *geminiGenerationConfig `json:"generationConfig,omitempty"`
|
||||
}
|
||||
|
||||
type geminiContent struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"`
|
||||
FunctionResponse *geminiFunctionResponse `json:"functionResponse,omitempty"`
|
||||
}
|
||||
|
||||
type geminiFunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]interface{} `json:"args"`
|
||||
}
|
||||
|
||||
type geminiFunctionResponse struct {
|
||||
Name string `json:"name"`
|
||||
Response map[string]interface{} `json:"response"`
|
||||
}
|
||||
|
||||
type geminiTool struct {
|
||||
FunctionDeclarations []geminiFunctionDeclaration `json:"functionDeclarations,omitempty"`
|
||||
}
|
||||
|
||||
type geminiFunctionDeclaration struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type geminiGenerationConfig struct {
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"topP,omitempty"`
|
||||
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||
StopSequences []string `json:"stopSequences,omitempty"`
|
||||
}
|
||||
|
||||
type geminiResponse struct {
|
||||
Candidates []struct {
|
||||
Content geminiContent `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
SafetyRatings []struct {
|
||||
Category string `json:"category"`
|
||||
Probability string `json:"probability"`
|
||||
} `json:"safetyRatings,omitempty"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata *struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
} `json:"usageMetadata,omitempty"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (g *Gemini) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {
|
||||
geminiReq := g.convertRequest(req)
|
||||
|
||||
url := g.buildURL(false)
|
||||
respBody, err := g.doRequest(ctx, url, geminiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var geminiResp geminiResponse
|
||||
if err := json.Unmarshal(respBody, &geminiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if geminiResp.Error != nil {
|
||||
return nil, fmt.Errorf("Gemini API error: %s", geminiResp.Error.Message)
|
||||
}
|
||||
|
||||
return g.convertResponse(&geminiResp), nil
|
||||
}
|
||||
|
||||
func (g *Gemini) GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error) {
|
||||
geminiReq := g.convertRequest(req)
|
||||
|
||||
jsonData, err := json.Marshal(geminiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := g.buildURL(true)
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
g.setHeaders(httpReq)
|
||||
|
||||
resp, err := g.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("Gemini API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
ch := make(chan StreamChunk, 100)
|
||||
go g.streamResponse(ctx, resp, ch)
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (g *Gemini) streamResponse(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {
|
||||
defer close(ch)
|
||||
defer resp.Body.Close()
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
var buffer strings.Builder
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ch <- StreamChunk{Done: true, Error: ctx.Err()}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
ch <- StreamChunk{Done: true, Error: err}
|
||||
} else {
|
||||
ch <- StreamChunk{Done: true}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Gemini streams JSON objects, accumulate until we have a complete one
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle SSE format if present
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
line = strings.TrimPrefix(line, "data: ")
|
||||
}
|
||||
|
||||
buffer.WriteString(line)
|
||||
|
||||
// Try to parse accumulated JSON
|
||||
var geminiResp geminiResponse
|
||||
if err := json.Unmarshal([]byte(buffer.String()), &geminiResp); err != nil {
|
||||
// Not complete yet, continue accumulating
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset buffer for next response
|
||||
buffer.Reset()
|
||||
|
||||
if len(geminiResp.Candidates) > 0 {
|
||||
candidate := geminiResp.Candidates[0]
|
||||
chunk := StreamChunk{
|
||||
FinishReason: candidate.FinishReason,
|
||||
}
|
||||
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.Text != "" {
|
||||
chunk.Content += part.Text
|
||||
}
|
||||
if part.FunctionCall != nil {
|
||||
argsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
||||
chunk.ToolCalls = append(chunk.ToolCalls, ToolCall{
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: string(argsJSON),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ch <- chunk
|
||||
|
||||
if candidate.FinishReason != "" && candidate.FinishReason != "STOP" {
|
||||
ch <- StreamChunk{Done: true}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gemini) convertRequest(req *GenerateRequest) *geminiRequest {
|
||||
geminiReq := &geminiRequest{
|
||||
GenerationConfig: &geminiGenerationConfig{
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
MaxOutputTokens: req.MaxTokens,
|
||||
StopSequences: req.Stop,
|
||||
},
|
||||
}
|
||||
|
||||
// Convert messages
|
||||
for _, msg := range req.Messages {
|
||||
if msg.Role == RoleSystem {
|
||||
geminiReq.SystemInstruction = &geminiContent{
|
||||
Parts: []geminiPart{{Text: msg.Content}},
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Map roles
|
||||
role := msg.Role
|
||||
if role == RoleAssistant {
|
||||
role = "model"
|
||||
}
|
||||
|
||||
geminiReq.Contents = append(geminiReq.Contents, geminiContent{
|
||||
Role: role,
|
||||
Parts: []geminiPart{{Text: msg.Content}},
|
||||
})
|
||||
}
|
||||
|
||||
// Convert tools
|
||||
if len(req.Tools) > 0 {
|
||||
var declarations []geminiFunctionDeclaration
|
||||
for _, tool := range req.Tools {
|
||||
declarations = append(declarations, geminiFunctionDeclaration{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.Parameters,
|
||||
})
|
||||
}
|
||||
geminiReq.Tools = []geminiTool{{FunctionDeclarations: declarations}}
|
||||
}
|
||||
|
||||
return geminiReq
|
||||
}
|
||||
|
||||
func (g *Gemini) convertResponse(resp *geminiResponse) *GenerateResponse {
|
||||
result := &GenerateResponse{}
|
||||
|
||||
if len(resp.Candidates) > 0 {
|
||||
candidate := resp.Candidates[0]
|
||||
result.FinishReason = candidate.FinishReason
|
||||
|
||||
var textParts []string
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.Text != "" {
|
||||
textParts = append(textParts, part.Text)
|
||||
}
|
||||
if part.FunctionCall != nil {
|
||||
argsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
||||
result.ToolCalls = append(result.ToolCalls, ToolCall{
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: string(argsJSON),
|
||||
})
|
||||
}
|
||||
}
|
||||
result.Content = strings.Join(textParts, "")
|
||||
}
|
||||
|
||||
if resp.UsageMetadata != nil {
|
||||
result.Usage = &Usage{
|
||||
PromptTokens: resp.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: resp.UsageMetadata.TotalTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (g *Gemini) buildURL(stream bool) string {
|
||||
action := "generateContent"
|
||||
if stream {
|
||||
action = "streamGenerateContent"
|
||||
}
|
||||
|
||||
// Check if baseURL already contains the full path
|
||||
if strings.Contains(g.config.BaseURL, ":generateContent") ||
|
||||
strings.Contains(g.config.BaseURL, ":streamGenerateContent") {
|
||||
return fmt.Sprintf("%s?key=%s", g.config.BaseURL, g.config.APIKey)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s:%s?key=%s",
|
||||
g.config.BaseURL,
|
||||
g.config.Model,
|
||||
action,
|
||||
g.config.APIKey,
|
||||
)
|
||||
}
|
||||
|
||||
func (g *Gemini) doRequest(ctx context.Context, url string, req *geminiRequest) ([]byte, error) {
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
g.setHeaders(httpReq)
|
||||
|
||||
resp, err := g.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("Gemini API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (g *Gemini) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for k, v := range g.config.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Chat is a convenience function for simple chat completions
|
||||
func Chat(ctx context.Context, llm LLM, messages []Message) (string, error) {
|
||||
resp, err := llm.Generate(ctx, &GenerateRequest{
|
||||
Messages: messages,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Content, nil
|
||||
}
|
||||
|
||||
// ChatWithSystem is a convenience function for chat with a system prompt
|
||||
func ChatWithSystem(ctx context.Context, llm LLM, systemPrompt string, userMessage string) (string, error) {
|
||||
messages := []Message{
|
||||
{Role: RoleSystem, Content: systemPrompt},
|
||||
{Role: RoleUser, Content: userMessage},
|
||||
}
|
||||
return Chat(ctx, llm, messages)
|
||||
}
|
||||
|
||||
// NewMessage creates a new message
|
||||
func NewMessage(role, content string) Message {
|
||||
return Message{Role: role, Content: content}
|
||||
}
|
||||
|
||||
// SystemMessage creates a system message
|
||||
func SystemMessage(content string) Message {
|
||||
return Message{Role: RoleSystem, Content: content}
|
||||
}
|
||||
|
||||
// UserMessage creates a user message
|
||||
func UserMessage(content string) Message {
|
||||
return Message{Role: RoleUser, Content: content}
|
||||
}
|
||||
|
||||
// AssistantMessage creates an assistant message
|
||||
func AssistantMessage(content string) Message {
|
||||
return Message{Role: RoleAssistant, Content: content}
|
||||
}
|
||||
|
||||
// DetectProvider attempts to detect the provider from the base URL
|
||||
func DetectProvider(baseURL string) string {
|
||||
baseURL = strings.ToLower(baseURL)
|
||||
|
||||
switch {
|
||||
case strings.Contains(baseURL, "anthropic.com"):
|
||||
return ProviderClaude
|
||||
case strings.Contains(baseURL, "generativelanguage.googleapis.com"):
|
||||
return ProviderGemini
|
||||
case strings.Contains(baseURL, "aiplatform.googleapis.com"):
|
||||
return ProviderVertex
|
||||
case strings.Contains(baseURL, "bedrock"):
|
||||
return ProviderBedrock
|
||||
case strings.Contains(baseURL, "localhost:11434"):
|
||||
return ProviderOllama
|
||||
default:
|
||||
// Default to OpenAI-compatible
|
||||
return ProviderOpenAI
|
||||
}
|
||||
}
|
||||
|
||||
// DetectProviderFromModel attempts to detect the provider from the model name
|
||||
func DetectProviderFromModel(model string) string {
|
||||
model = strings.ToLower(model)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(model, "claude"):
|
||||
return ProviderClaude
|
||||
case strings.HasPrefix(model, "gemini"):
|
||||
return ProviderGemini
|
||||
case strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o1") || strings.HasPrefix(model, "o3"):
|
||||
return ProviderOpenAI
|
||||
case strings.HasPrefix(model, "llama") || strings.HasPrefix(model, "mistral") || strings.HasPrefix(model, "qwen"):
|
||||
return ProviderOllama
|
||||
default:
|
||||
return ProviderOpenAI
|
||||
}
|
||||
}
|
||||
|
||||
// BuildToolDefinition creates a tool definition with JSON schema parameters
|
||||
func BuildToolDefinition(name, description string, properties map[string]interface{}, required []string) ToolDefinition {
|
||||
params := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
}
|
||||
if len(required) > 0 {
|
||||
params["required"] = required
|
||||
}
|
||||
|
||||
return ToolDefinition{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Parameters: params,
|
||||
}
|
||||
}
|
||||
|
||||
// CollectStream collects all chunks from a stream into a single response
|
||||
func CollectStream(ch <-chan StreamChunk) (*GenerateResponse, error) {
|
||||
var content strings.Builder
|
||||
var toolCalls []ToolCall
|
||||
var finishReason string
|
||||
var lastErr error
|
||||
|
||||
for chunk := range ch {
|
||||
if chunk.Error != nil {
|
||||
lastErr = chunk.Error
|
||||
}
|
||||
if chunk.Content != "" {
|
||||
content.WriteString(chunk.Content)
|
||||
}
|
||||
if len(chunk.ToolCalls) > 0 {
|
||||
toolCalls = append(toolCalls, chunk.ToolCalls...)
|
||||
}
|
||||
if chunk.FinishReason != "" {
|
||||
finishReason = chunk.FinishReason
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
return &GenerateResponse{
|
||||
Content: content.String(),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// Package llm provides a unified interface for multiple LLM providers.
|
||||
// Supports OpenAI-compatible APIs, Claude/Anthropic, and Gemini.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Provider types
|
||||
const (
|
||||
ProviderOpenAI = "openai" // OpenAI and compatible APIs (Azure, vLLM, etc.)
|
||||
ProviderClaude = "claude" // Anthropic Claude
|
||||
ProviderGemini = "gemini" // Google Gemini
|
||||
ProviderOllama = "ollama" // Ollama local models
|
||||
ProviderBedrock = "bedrock" // AWS Bedrock
|
||||
ProviderVertex = "vertex" // Google Vertex AI
|
||||
)
|
||||
|
||||
// Role constants
|
||||
const (
|
||||
RoleSystem = "system"
|
||||
RoleUser = "user"
|
||||
RoleAssistant = "assistant"
|
||||
)
|
||||
|
||||
// Message represents a chat message
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ToolCall represents a tool/function call from the LLM
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
// ToolDefinition defines a tool that the LLM can call
|
||||
type ToolDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateRequest is the unified request for LLM generation
|
||||
type GenerateRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []ToolDefinition `json:"tools,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
Stop []string `json:"stop,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateResponse is the unified response from LLM generation
|
||||
type GenerateResponse struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// Usage represents token usage statistics
|
||||
type Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// StreamChunk represents a chunk in streaming response
|
||||
type StreamChunk struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
Error error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LLM is the unified interface for all LLM providers
|
||||
type LLM interface {
|
||||
// Name returns the provider name
|
||||
Name() string
|
||||
|
||||
// Generate sends a request to the LLM and returns the response
|
||||
Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error)
|
||||
|
||||
// GenerateStream sends a request and returns a channel for streaming responses
|
||||
GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error)
|
||||
}
|
||||
|
||||
// Config is the configuration for creating an LLM provider
|
||||
type Config struct {
|
||||
// Provider type: openai, claude, gemini, ollama, bedrock, vertex
|
||||
Provider string `json:"provider"`
|
||||
|
||||
// API endpoint URL
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
|
||||
// API key or token
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
|
||||
// Model name (e.g., "gpt-4", "claude-3-opus", "gemini-pro")
|
||||
Model string `json:"model"`
|
||||
|
||||
// Additional headers for API requests
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
|
||||
// HTTP timeout in milliseconds
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
|
||||
// Skip SSL verification (for self-signed certs)
|
||||
SkipSSLVerify bool `json:"skip_ssl_verify,omitempty"`
|
||||
|
||||
// HTTP proxy URL
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
|
||||
// Provider-specific options
|
||||
Options map[string]interface{} `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns a config with default values
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Provider: ProviderOpenAI,
|
||||
Timeout: 60000,
|
||||
}
|
||||
}
|
||||
|
||||
// New creates an LLM instance based on the config
|
||||
func New(cfg *Config) (LLM, error) {
|
||||
if cfg == nil {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
|
||||
// Create HTTP client
|
||||
client := createHTTPClient(cfg)
|
||||
|
||||
switch cfg.Provider {
|
||||
case ProviderOpenAI, "":
|
||||
return NewOpenAI(cfg, client)
|
||||
case ProviderClaude:
|
||||
return NewClaude(cfg, client)
|
||||
case ProviderGemini:
|
||||
return NewGemini(cfg, client)
|
||||
case ProviderOllama:
|
||||
// Ollama uses OpenAI-compatible API
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = "http://localhost:11434/v1"
|
||||
}
|
||||
return NewOpenAI(cfg, client)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported LLM provider: %s", cfg.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// createHTTPClient creates an HTTP client with the given config
|
||||
func createHTTPClient(cfg *Config) *http.Client {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: cfg.SkipSSLVerify,
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.Proxy != "" {
|
||||
if proxyURL, err := url.Parse(cfg.Proxy); err == nil {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 60000
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: time.Duration(timeout) * time.Millisecond,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert internal messages to provider-specific format
|
||||
func ConvertMessages(messages []Message) []Message {
|
||||
result := make([]Message, len(messages))
|
||||
copy(result, messages)
|
||||
return result
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultOpenAIURL = "https://api.openai.com/v1/chat/completions"
|
||||
|
||||
// 重试相关配置
|
||||
maxRetries = 3
|
||||
initialRetryWait = 5 * time.Second // rate limit 时初始等待 5 秒
|
||||
maxRetryWait = 60 * time.Second // 最大等待 60 秒
|
||||
)
|
||||
|
||||
// OpenAI implements the LLM interface for OpenAI and compatible APIs
|
||||
type OpenAI struct {
|
||||
config *Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewOpenAI creates a new OpenAI provider
|
||||
func NewOpenAI(cfg *Config, client *http.Client) (*OpenAI, error) {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = DefaultOpenAIURL
|
||||
}
|
||||
return &OpenAI{
|
||||
config: cfg,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *OpenAI) Name() string {
|
||||
return ProviderOpenAI
|
||||
}
|
||||
|
||||
// OpenAI API request/response structures
|
||||
type openAIRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openAIMessage `json:"messages"`
|
||||
Tools []openAITool `json:"tools,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
Stop []string `json:"stop,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
type openAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
type openAITool struct {
|
||||
Type string `json:"type"`
|
||||
Function openAIFunction `json:"function"`
|
||||
}
|
||||
|
||||
type openAIFunction struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type openAIToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
type openAIResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Index int `json:"index"`
|
||||
Message openAIMessage `json:"message"`
|
||||
Delta openAIMessage `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage,omitempty"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (o *OpenAI) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {
|
||||
// Convert to OpenAI format
|
||||
openAIReq := o.convertRequest(req)
|
||||
openAIReq.Stream = false
|
||||
|
||||
// Make request
|
||||
respBody, err := o.doRequest(ctx, openAIReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var openAIResp openAIResponse
|
||||
if err := json.Unmarshal(respBody, &openAIResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if openAIResp.Error != nil {
|
||||
return nil, fmt.Errorf("OpenAI API error: %s", openAIResp.Error.Message)
|
||||
}
|
||||
|
||||
if len(openAIResp.Choices) == 0 {
|
||||
return nil, fmt.Errorf("no response from OpenAI")
|
||||
}
|
||||
|
||||
// Convert to unified response
|
||||
return o.convertResponse(&openAIResp), nil
|
||||
}
|
||||
|
||||
// isRetryableStatus 检查是否是可重试的 HTTP 状态码
|
||||
func isRetryableStatus(statusCode int) bool {
|
||||
switch statusCode {
|
||||
case 429, 500, 502, 503, 504:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (o *OpenAI) GenerateStream(ctx context.Context, req *GenerateRequest) (<-chan StreamChunk, error) {
|
||||
// Convert to OpenAI format
|
||||
openAIReq := o.convertRequest(req)
|
||||
openAIReq.Stream = true
|
||||
|
||||
// Create request body
|
||||
jsonData, err := json.Marshal(openAIReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
var resp *http.Response
|
||||
var lastErr error
|
||||
retryWait := initialRetryWait
|
||||
|
||||
// 重试循环
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// 等待后重试
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(retryWait):
|
||||
}
|
||||
// 指数退避,但不超过最大等待时间
|
||||
retryWait *= 2
|
||||
if retryWait > maxRetryWait {
|
||||
retryWait = maxRetryWait
|
||||
}
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", o.config.BaseURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
o.setHeaders(httpReq)
|
||||
|
||||
// Make request
|
||||
resp, err = o.client.Do(httpReq)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("failed to send request: %w", err)
|
||||
continue // 网络错误,重试
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("OpenAI API error (status %d): %s", resp.StatusCode, string(body))
|
||||
|
||||
// 检查是否可重试
|
||||
if isRetryableStatus(resp.StatusCode) && attempt < maxRetries {
|
||||
continue // 可重试的错误,继续重试
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// 成功,跳出循环
|
||||
break
|
||||
}
|
||||
|
||||
if resp == nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// Create channel and start streaming
|
||||
ch := make(chan StreamChunk, 100)
|
||||
go o.streamResponse(ctx, resp, ch)
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (o *OpenAI) streamResponse(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) {
|
||||
defer close(ch)
|
||||
defer resp.Body.Close()
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ch <- StreamChunk{Done: true, Error: ctx.Err()}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
ch <- StreamChunk{Done: true, Error: err}
|
||||
} else {
|
||||
ch <- StreamChunk{Done: true}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
ch <- StreamChunk{Done: true}
|
||||
return
|
||||
}
|
||||
|
||||
var streamResp openAIResponse
|
||||
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(streamResp.Choices) > 0 {
|
||||
delta := streamResp.Choices[0].Delta
|
||||
chunk := StreamChunk{
|
||||
Content: delta.Content,
|
||||
FinishReason: streamResp.Choices[0].FinishReason,
|
||||
}
|
||||
|
||||
// Handle tool calls in stream
|
||||
if len(delta.ToolCalls) > 0 {
|
||||
for _, tc := range delta.ToolCalls {
|
||||
chunk.ToolCalls = append(chunk.ToolCalls, ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ch <- chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *OpenAI) convertRequest(req *GenerateRequest) *openAIRequest {
|
||||
openAIReq := &openAIRequest{
|
||||
Model: o.config.Model,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
Stop: req.Stop,
|
||||
}
|
||||
|
||||
// Convert messages
|
||||
for _, msg := range req.Messages {
|
||||
openAIReq.Messages = append(openAIReq.Messages, openAIMessage{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Convert tools
|
||||
for _, tool := range req.Tools {
|
||||
openAIReq.Tools = append(openAIReq.Tools, openAITool{
|
||||
Type: "function",
|
||||
Function: openAIFunction{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return openAIReq
|
||||
}
|
||||
|
||||
func (o *OpenAI) convertResponse(resp *openAIResponse) *GenerateResponse {
|
||||
result := &GenerateResponse{}
|
||||
|
||||
if len(resp.Choices) > 0 {
|
||||
choice := resp.Choices[0]
|
||||
result.Content = choice.Message.Content
|
||||
result.FinishReason = choice.FinishReason
|
||||
|
||||
// Convert tool calls
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
result.ToolCalls = append(result.ToolCalls, ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Usage != nil {
|
||||
result.Usage = &Usage{
|
||||
PromptTokens: resp.Usage.PromptTokens,
|
||||
CompletionTokens: resp.Usage.CompletionTokens,
|
||||
TotalTokens: resp.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (o *OpenAI) doRequest(ctx context.Context, req *openAIRequest) ([]byte, error) {
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
retryWait := initialRetryWait
|
||||
|
||||
// 重试循环
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// 等待后重试
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(retryWait):
|
||||
}
|
||||
// 指数退避
|
||||
retryWait *= 2
|
||||
if retryWait > maxRetryWait {
|
||||
retryWait = maxRetryWait
|
||||
}
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", o.config.BaseURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
o.setHeaders(httpReq)
|
||||
|
||||
resp, err := o.client.Do(httpReq)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("failed to send request: %w", err)
|
||||
continue // 网络错误,重试
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
lastErr = fmt.Errorf("OpenAI API error (status %d): %s", resp.StatusCode, string(body))
|
||||
// 检查是否可重试
|
||||
if isRetryableStatus(resp.StatusCode) && attempt < maxRetries {
|
||||
continue
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (o *OpenAI) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if o.config.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+o.config.APIKey)
|
||||
}
|
||||
|
||||
for k, v := range o.config.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ToolInfo 工具信息(用于提示词构建)
|
||||
type ToolInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters []ToolParamInfo
|
||||
}
|
||||
|
||||
// ToolParamInfo 工具参数信息
|
||||
type ToolParamInfo struct {
|
||||
Name string
|
||||
Type string
|
||||
Description string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// PromptData 提示词模板数据
|
||||
type PromptData struct {
|
||||
Platform string // 操作系统
|
||||
Date string // 当前日期
|
||||
}
|
||||
|
||||
// BuildToolsSection 构建工具描述段落
|
||||
func BuildToolsSection(tools []ToolInfo) string {
|
||||
if len(tools) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Available Tools\n\n")
|
||||
|
||||
for _, tool := range tools {
|
||||
sb.WriteString(fmt.Sprintf("### %s\n", tool.Name))
|
||||
sb.WriteString(fmt.Sprintf("%s\n", tool.Description))
|
||||
|
||||
if len(tool.Parameters) > 0 {
|
||||
sb.WriteString("Parameters:\n")
|
||||
for _, param := range tool.Parameters {
|
||||
required := ""
|
||||
if param.Required {
|
||||
required = " (required)"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- %s (%s)%s: %s\n", param.Name, param.Type, required, param.Description))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BuildToolsListBrief 构建简洁的工具列表(用于 Plan 模式)
|
||||
func BuildToolsListBrief(tools []ToolInfo) string {
|
||||
if len(tools) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Available Tools\n\n")
|
||||
|
||||
for _, tool := range tools {
|
||||
sb.WriteString(fmt.Sprintf("- **%s**: %s\n", tool.Name, tool.Description))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BuildEnvSection 构建环境信息段落
|
||||
func BuildEnvSection() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Environment\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- Platform: %s\n", runtime.GOOS))
|
||||
sb.WriteString(fmt.Sprintf("- Date: %s\n", time.Now().Format("2006-01-02")))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BuildSkillsSection 构建技能指导段落
|
||||
func BuildSkillsSection(skillContents []string) string {
|
||||
if len(skillContents) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 专项技能指导\n\n")
|
||||
|
||||
if len(skillContents) == 1 {
|
||||
sb.WriteString("你已被加载以下专项技能,请参考技能中的流程:\n\n")
|
||||
sb.WriteString(skillContents[0])
|
||||
sb.WriteString("\n\n")
|
||||
} else {
|
||||
sb.WriteString("你已被加载以下专项技能,请参考技能中的流程来制定执行计划:\n\n")
|
||||
for i, content := range skillContents {
|
||||
sb.WriteString(fmt.Sprintf("### 技能 %d\n\n", i+1))
|
||||
sb.WriteString(content)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BuildPreviousFindingsSection 构建之前发现段落
|
||||
func BuildPreviousFindingsSection(findings []string) string {
|
||||
if len(findings) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Previous Findings\n\n")
|
||||
for _, finding := range findings {
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", finding))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BuildCurrentStepSection 构建当前步骤段落
|
||||
func BuildCurrentStepSection(goal, approach string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Current Step\n\n")
|
||||
sb.WriteString(fmt.Sprintf("**Goal**: %s\n", goal))
|
||||
sb.WriteString(fmt.Sprintf("**Approach**: %s\n\n", approach))
|
||||
return sb.String()
|
||||
}
|
||||
571
aiagent/mcp.go
571
aiagent/mcp.go
@@ -1,571 +0,0 @@
|
||||
package aiagent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// MCP 传输类型
|
||||
MCPTransportStdio = "stdio" // 标准输入/输出传输
|
||||
MCPTransportSSE = "sse" // HTTP Server-Sent Events 传输
|
||||
|
||||
// 默认超时
|
||||
DefaultMCPTimeout = 30000 // 30 秒
|
||||
DefaultMCPConnectTimeout = 10000 // 10 秒
|
||||
)
|
||||
|
||||
// MCPConfig MCP 服务器配置(在 AIAgentConfig 中使用)
|
||||
type MCPConfig struct {
|
||||
// MCP 服务器列表
|
||||
Servers []MCPServerConfig `json:"servers"`
|
||||
}
|
||||
|
||||
// MCPServerConfig 单个 MCP 服务器配置
|
||||
type MCPServerConfig struct {
|
||||
// 服务器名称(唯一标识)
|
||||
Name string `json:"name"`
|
||||
|
||||
// 传输类型:stdio 或 sse
|
||||
Transport string `json:"transport"`
|
||||
|
||||
// === stdio 传输配置 ===
|
||||
Command string `json:"command,omitempty"` // 启动命令
|
||||
Args []string `json:"args,omitempty"` // 命令参数
|
||||
Env map[string]string `json:"env,omitempty"` // 环境变量(支持 ${VAR} 从系统环境变量读取)
|
||||
|
||||
// === SSE 传输配置 ===
|
||||
URL string `json:"url,omitempty"` // SSE 服务器 URL
|
||||
Headers map[string]string `json:"headers,omitempty"` // 请求头(支持 ${VAR} 从系统环境变量读取)
|
||||
SkipSSLVerify bool `json:"skip_ssl_verify,omitempty"` // 跳过 SSL 验证
|
||||
|
||||
// === 鉴权配置(SSE 传输)===
|
||||
// 便捷鉴权配置,会自动设置对应的 Header
|
||||
AuthType string `json:"auth_type,omitempty"` // 鉴权类型:bearer, api_key, basic
|
||||
APIKey string `json:"api_key,omitempty"` // API Key(支持 ${VAR} 从系统环境变量读取)
|
||||
Username string `json:"username,omitempty"` // Basic Auth 用户名
|
||||
Password string `json:"password,omitempty"` // Basic Auth 密码(支持 ${VAR})
|
||||
|
||||
// 通用配置
|
||||
Timeout int `json:"timeout,omitempty"` // 工具调用超时(毫秒)
|
||||
ConnectTimeout int `json:"connect_timeout,omitempty"` // 连接超时(毫秒)
|
||||
}
|
||||
|
||||
// MCPToolConfig MCP 工具配置(在 AgentTool 中使用)
|
||||
type MCPToolConfig struct {
|
||||
// MCP 服务器名称(引用 MCPConfig.Servers 中的配置)
|
||||
ServerName string `json:"server_name"`
|
||||
|
||||
// 工具名称(MCP 服务器返回的工具名)
|
||||
ToolName string `json:"tool_name"`
|
||||
}
|
||||
|
||||
// MCPTool MCP 工具定义(用于内部表示)
|
||||
type MCPTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema map[string]interface{} `json:"inputSchema,omitempty"`
|
||||
}
|
||||
|
||||
// MCPToolsCallResult 工具调用结果
|
||||
type MCPToolsCallResult struct {
|
||||
Content []MCPContent `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// MCPContent 工具返回内容
|
||||
type MCPContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
}
|
||||
|
||||
// MCPClient MCP 客户端(基于官方 go-sdk)
|
||||
type MCPClient struct {
|
||||
config *MCPServerConfig
|
||||
|
||||
// SDK 客户端和会话(stdio 传输)
|
||||
client *mcp.Client
|
||||
session *mcp.ClientSession
|
||||
|
||||
// SSE 传输(SDK 暂不支持 SSE 客户端,保留自定义实现)
|
||||
httpClient *http.Client
|
||||
sseURL string
|
||||
|
||||
// 通用
|
||||
mu sync.Mutex
|
||||
initialized bool
|
||||
tools []MCPTool // 缓存的工具列表
|
||||
}
|
||||
|
||||
// expandEnvVars 展开字符串中的环境变量引用
|
||||
func expandEnvVars(s string) string {
|
||||
return os.ExpandEnv(s)
|
||||
}
|
||||
|
||||
// NewMCPClient 创建 MCP 客户端
|
||||
func NewMCPClient(config *MCPServerConfig) (*MCPClient, error) {
|
||||
client := &MCPClient{
|
||||
config: config,
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Connect 连接到 MCP 服务器
|
||||
func (c *MCPClient) Connect(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.initialized {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
switch c.config.Transport {
|
||||
case MCPTransportStdio:
|
||||
err = c.connectStdio(ctx)
|
||||
case MCPTransportSSE:
|
||||
err = c.connectSSE(ctx)
|
||||
default:
|
||||
return fmt.Errorf("unsupported MCP transport: %s", c.config.Transport)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.initialized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectStdio 通过 stdio 连接(使用官方 SDK)
|
||||
func (c *MCPClient) connectStdio(ctx context.Context) error {
|
||||
if c.config.Command == "" {
|
||||
return fmt.Errorf("stdio transport requires command")
|
||||
}
|
||||
|
||||
// 准备环境变量
|
||||
env := os.Environ()
|
||||
for k, v := range c.config.Env {
|
||||
expandedValue := expandEnvVars(v)
|
||||
env = append(env, fmt.Sprintf("%s=%s", k, expandedValue))
|
||||
}
|
||||
|
||||
// 创建 exec.Cmd
|
||||
cmd := exec.CommandContext(ctx, c.config.Command, c.config.Args...)
|
||||
cmd.Env = env
|
||||
|
||||
// 使用官方 SDK 的 CommandTransport
|
||||
transport := &mcp.CommandTransport{
|
||||
Command: cmd,
|
||||
}
|
||||
|
||||
// 创建 MCP 客户端
|
||||
c.client = mcp.NewClient(
|
||||
&mcp.Implementation{
|
||||
Name: "nightingale-aiagent",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
// 连接并初始化(Connect 会自动进行 initialize 握手)
|
||||
session, err := c.client.Connect(ctx, transport, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect MCP client: %v", err)
|
||||
}
|
||||
c.session = session
|
||||
|
||||
logger.Infof("MCP stdio server started: %s", c.config.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectSSE 通过 SSE 连接(保留自定义实现,SDK 暂不支持 SSE 客户端)
|
||||
func (c *MCPClient) connectSSE(ctx context.Context) error {
|
||||
if c.config.URL == "" {
|
||||
return fmt.Errorf("SSE transport requires URL")
|
||||
}
|
||||
|
||||
// 创建 HTTP 客户端
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: c.config.SkipSSLVerify},
|
||||
}
|
||||
|
||||
timeout := c.config.ConnectTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultMCPConnectTimeout
|
||||
}
|
||||
|
||||
c.httpClient = &http.Client{
|
||||
Timeout: time.Duration(timeout) * time.Millisecond,
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
c.sseURL = c.config.URL
|
||||
|
||||
logger.Infof("MCP SSE client configured: %s", c.config.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTools 获取工具列表
|
||||
func (c *MCPClient) ListTools(ctx context.Context) ([]MCPTool, error) {
|
||||
c.mu.Lock()
|
||||
if len(c.tools) > 0 {
|
||||
tools := c.tools
|
||||
c.mu.Unlock()
|
||||
return tools, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
var tools []MCPTool
|
||||
|
||||
switch c.config.Transport {
|
||||
case MCPTransportStdio:
|
||||
// 使用官方 SDK
|
||||
if c.session == nil {
|
||||
return nil, fmt.Errorf("MCP session not initialized")
|
||||
}
|
||||
|
||||
result, err := c.session.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list tools: %v", err)
|
||||
}
|
||||
|
||||
// 转换为内部格式
|
||||
for _, tool := range result.Tools {
|
||||
inputSchema := make(map[string]interface{})
|
||||
if tool.InputSchema != nil {
|
||||
// 将 SDK 的 InputSchema 转换为 map
|
||||
schemaBytes, err := json.Marshal(tool.InputSchema)
|
||||
if err == nil {
|
||||
json.Unmarshal(schemaBytes, &inputSchema)
|
||||
}
|
||||
}
|
||||
|
||||
tools = append(tools, MCPTool{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
InputSchema: inputSchema,
|
||||
})
|
||||
}
|
||||
|
||||
case MCPTransportSSE:
|
||||
// 使用自定义 HTTP 实现
|
||||
var err error
|
||||
tools, err = c.listToolsSSE(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.tools = tools
|
||||
c.mu.Unlock()
|
||||
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
// listToolsSSE 通过 SSE 获取工具列表
|
||||
func (c *MCPClient) listToolsSSE(ctx context.Context) ([]MCPTool, error) {
|
||||
req := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
}
|
||||
|
||||
resp, err := c.sendSSERequest(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultBytes, _ := json.Marshal(resp["result"])
|
||||
var result struct {
|
||||
Tools []MCPTool `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(resultBytes, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse tools list: %v", err)
|
||||
}
|
||||
|
||||
return result.Tools, nil
|
||||
}
|
||||
|
||||
// CallTool 调用工具
|
||||
func (c *MCPClient) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*MCPToolsCallResult, error) {
|
||||
switch c.config.Transport {
|
||||
case MCPTransportStdio:
|
||||
return c.callToolStdio(ctx, name, arguments)
|
||||
case MCPTransportSSE:
|
||||
return c.callToolSSE(ctx, name, arguments)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported transport: %s", c.config.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
// callToolStdio 通过 stdio 调用工具(使用官方 SDK)
|
||||
func (c *MCPClient) callToolStdio(ctx context.Context, name string, arguments map[string]interface{}) (*MCPToolsCallResult, error) {
|
||||
if c.session == nil {
|
||||
return nil, fmt.Errorf("MCP session not initialized")
|
||||
}
|
||||
|
||||
// 调用工具
|
||||
result, err := c.session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: name,
|
||||
Arguments: arguments,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool call failed: %v", err)
|
||||
}
|
||||
|
||||
// 转换结果
|
||||
mcpResult := &MCPToolsCallResult{
|
||||
IsError: result.IsError,
|
||||
}
|
||||
|
||||
for _, content := range result.Content {
|
||||
mc := MCPContent{}
|
||||
|
||||
// 根据具体类型提取内容
|
||||
switch c := content.(type) {
|
||||
case *mcp.TextContent:
|
||||
mc.Type = "text"
|
||||
mc.Text = c.Text
|
||||
case *mcp.ImageContent:
|
||||
mc.Type = "image"
|
||||
mc.Data = string(c.Data)
|
||||
mc.MimeType = c.MIMEType
|
||||
case *mcp.AudioContent:
|
||||
mc.Type = "audio"
|
||||
mc.Data = string(c.Data)
|
||||
mc.MimeType = c.MIMEType
|
||||
case *mcp.EmbeddedResource:
|
||||
mc.Type = "resource"
|
||||
if c.Resource != nil {
|
||||
if c.Resource.Text != "" {
|
||||
mc.Text = c.Resource.Text
|
||||
} else if c.Resource.Blob != nil {
|
||||
mc.Data = string(c.Resource.Blob)
|
||||
}
|
||||
mc.MimeType = c.Resource.MIMEType
|
||||
}
|
||||
case *mcp.ResourceLink:
|
||||
mc.Type = "resource_link"
|
||||
mc.Text = c.URI
|
||||
default:
|
||||
// 尝试通过 JSON 序列化获取内容
|
||||
if data, err := json.Marshal(content); err == nil {
|
||||
mc.Type = "unknown"
|
||||
mc.Text = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
mcpResult.Content = append(mcpResult.Content, mc)
|
||||
}
|
||||
|
||||
return mcpResult, nil
|
||||
}
|
||||
|
||||
// callToolSSE 通过 SSE 调用工具
|
||||
func (c *MCPClient) callToolSSE(ctx context.Context, name string, arguments map[string]interface{}) (*MCPToolsCallResult, error) {
|
||||
req := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": map[string]interface{}{
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := c.sendSSERequest(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if errObj, ok := resp["error"].(map[string]interface{}); ok {
|
||||
return nil, fmt.Errorf("MCP error: %v", errObj["message"])
|
||||
}
|
||||
|
||||
resultBytes, _ := json.Marshal(resp["result"])
|
||||
var result MCPToolsCallResult
|
||||
if err := json.Unmarshal(resultBytes, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse tool call result: %v", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// setAuthHeaders 设置鉴权请求头
|
||||
func (c *MCPClient) setAuthHeaders(req *http.Request) {
|
||||
cfg := c.config
|
||||
|
||||
if cfg.AuthType == "" && cfg.APIKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := expandEnvVars(cfg.APIKey)
|
||||
username := expandEnvVars(cfg.Username)
|
||||
password := expandEnvVars(cfg.Password)
|
||||
|
||||
switch strings.ToLower(cfg.AuthType) {
|
||||
case "bearer":
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
case "api_key", "apikey":
|
||||
if apiKey != "" {
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
}
|
||||
case "basic":
|
||||
if username != "" {
|
||||
req.SetBasicAuth(username, password)
|
||||
}
|
||||
default:
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendSSERequest 通过 HTTP 发送请求
|
||||
func (c *MCPClient) sendSSERequest(ctx context.Context, req map[string]interface{}) (map[string]interface{}, error) {
|
||||
baseURL := c.sseURL
|
||||
if !strings.HasSuffix(baseURL, "/") {
|
||||
baseURL += "/"
|
||||
}
|
||||
|
||||
postURL := baseURL + "message"
|
||||
if _, err := url.Parse(postURL); err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %v", err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %v", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", postURL, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP request: %v", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
c.setAuthHeaders(httpReq)
|
||||
|
||||
for k, v := range c.config.Headers {
|
||||
httpReq.Header.Set(k, expandEnvVars(v))
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *MCPClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.session != nil {
|
||||
c.session.Close()
|
||||
c.session = nil
|
||||
}
|
||||
|
||||
c.client = nil
|
||||
c.initialized = false
|
||||
logger.Infof("MCP client closed: %s", c.config.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MCPClientManager MCP 客户端管理器
|
||||
type MCPClientManager struct {
|
||||
clients map[string]*MCPClient
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMCPClientManager 创建 MCP 客户端管理器
|
||||
func NewMCPClientManager() *MCPClientManager {
|
||||
return &MCPClientManager{
|
||||
clients: make(map[string]*MCPClient),
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrCreateClient 获取或创建 MCP 客户端
|
||||
func (m *MCPClientManager) GetOrCreateClient(ctx context.Context, config *MCPServerConfig) (*MCPClient, error) {
|
||||
m.mu.RLock()
|
||||
client, ok := m.clients[config.Name]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// 再次检查(double-check locking)
|
||||
if client, ok := m.clients[config.Name]; ok {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// 创建新客户端
|
||||
client, err := NewMCPClient(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 连接
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.clients[config.Name] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// CloseAll 关闭所有客户端
|
||||
func (m *MCPClientManager) CloseAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for name, client := range m.clients {
|
||||
if err := client.Close(); err != nil {
|
||||
logger.Warningf("Failed to close MCP client %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
m.clients = make(map[string]*MCPClient)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
// ReAct 模式系统提示词
|
||||
//
|
||||
//go:embed react_system.md
|
||||
var ReactSystemPrompt string
|
||||
|
||||
// Plan+ReAct 模式规划阶段系统提示词
|
||||
//
|
||||
//go:embed plan_system.md
|
||||
var PlanSystemPrompt string
|
||||
|
||||
// 步骤执行提示词
|
||||
//
|
||||
//go:embed step_execution.md
|
||||
var StepExecutionPrompt string
|
||||
|
||||
// 综合分析提示词
|
||||
//
|
||||
//go:embed synthesis.md
|
||||
var SynthesisPrompt string
|
||||
|
||||
// 用户提示词默认模板
|
||||
//
|
||||
//go:embed user_default.md
|
||||
var UserDefaultTemplate string
|
||||
@@ -1,65 +0,0 @@
|
||||
You are an intelligent AI Agent capable of analyzing tasks, creating execution plans, and solving complex problems.
|
||||
|
||||
Your role is to understand user requests and create structured, actionable execution plans.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- **Alert Analysis**: Analyze alerts, investigate root causes, correlate events
|
||||
- **Data Analysis**: Analyze batch data, identify patterns, generate insights
|
||||
- **SQL Generation**: Convert natural language to SQL queries
|
||||
- **General Problem Solving**: Break down complex tasks into actionable steps
|
||||
|
||||
## Planning Principles
|
||||
|
||||
1. **Understand First**: Carefully analyze what the user is asking for
|
||||
2. **Identify Key Areas**: Determine which domains, systems, or aspects are involved
|
||||
3. **Create Logical Steps**: Order steps by priority or logical sequence
|
||||
4. **Be Specific**: Each step should have a clear goal and concrete approach
|
||||
5. **Reference Tools**: Consider available tools when designing your approach
|
||||
|
||||
## Response Format
|
||||
|
||||
You must respond in the following JSON format:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_summary": "Brief summary of the input/request",
|
||||
"goal": "The overall goal of this task",
|
||||
"focus_areas": ["area1", "area2", "area3"],
|
||||
"steps": [
|
||||
{
|
||||
"step_number": 1,
|
||||
"goal": "What to accomplish in this step",
|
||||
"approach": "How to accomplish it (which tools/methods to use)"
|
||||
},
|
||||
{
|
||||
"step_number": 2,
|
||||
"goal": "...",
|
||||
"approach": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Areas by Task Type
|
||||
|
||||
**Alert/Incident Analysis:**
|
||||
- Network: latency, packet loss, DNS resolution
|
||||
- Database: query performance, connections, locks, replication
|
||||
- Application: error rates, response times, resource usage
|
||||
- Infrastructure: CPU, memory, disk I/O, network throughput
|
||||
|
||||
**Batch Alert Analysis:**
|
||||
- Pattern recognition: common labels, time correlation
|
||||
- Aggregation: group by severity, source, category
|
||||
- Trend analysis: frequency, escalation patterns
|
||||
|
||||
**SQL Generation:**
|
||||
- Schema understanding: tables, columns, relationships
|
||||
- Query optimization: indexes, join strategies
|
||||
- Data validation: constraints, data types
|
||||
|
||||
**General Analysis:**
|
||||
- Data collection: gather relevant information
|
||||
- Processing: transform, filter, aggregate
|
||||
- Output: format results appropriately
|
||||
@@ -1,42 +0,0 @@
|
||||
You are an intelligent AI Agent capable of analyzing tasks, creating execution plans, and solving complex problems.
|
||||
|
||||
Your capabilities include but are not limited to:
|
||||
- **Root Cause Analysis**: Analyze alerts, investigate incidents, identify root causes
|
||||
- **Data Analysis**: Query and analyze metrics, logs, traces, and other data sources
|
||||
- **SQL Generation**: Convert natural language queries to SQL statements
|
||||
- **Information Synthesis**: Summarize and extract insights from complex data
|
||||
- **Content Generation**: Generate titles, summaries, and structured reports
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Systematic Analysis**: Gather sufficient information before making conclusions
|
||||
2. **Evidence-Based**: Support conclusions with specific data from tool outputs
|
||||
3. **Tool Efficiency**: Use tools wisely, avoid redundant calls
|
||||
4. **Clear Communication**: Keep responses focused and actionable
|
||||
5. **Adaptability**: Adjust your approach based on the task type
|
||||
|
||||
## Response Format
|
||||
|
||||
You must respond in the following format:
|
||||
|
||||
```
|
||||
Thought: [Your reasoning about the current situation and what to do next]
|
||||
Action: [The tool name to use, or 'Final Answer' if you have enough information]
|
||||
Action Input: [The input to the action - for tools, provide JSON parameters; for Final Answer, provide your result]
|
||||
```
|
||||
|
||||
## Task Guidelines
|
||||
|
||||
1. **Understand the request**: Carefully analyze what the user is asking for
|
||||
2. **Choose appropriate tools**: Select tools that best fit the task requirements
|
||||
3. **Iterate as needed**: Gather additional information if initial results are insufficient
|
||||
4. **Validate results**: Verify your conclusions before providing the final answer
|
||||
5. **Be concise**: Provide clear, well-structured responses
|
||||
|
||||
## Final Answer Requirements
|
||||
|
||||
Your Final Answer should:
|
||||
- Directly address the user's request
|
||||
- Be well-structured and easy to understand
|
||||
- Include supporting evidence or reasoning when applicable
|
||||
- Provide actionable recommendations if relevant
|
||||
@@ -1,35 +0,0 @@
|
||||
You are an intelligent AI Agent executing a specific step as part of a larger execution plan.
|
||||
|
||||
## Your Task
|
||||
|
||||
Focus on completing the current step efficiently and thoroughly. Use the available tools to gather information, process data, or generate results as needed to achieve the step's goal.
|
||||
|
||||
## Response Format
|
||||
|
||||
Respond in this format:
|
||||
|
||||
```
|
||||
Thought: [Your reasoning about what to do for this step]
|
||||
Action: [Tool name or 'Step Complete' when done]
|
||||
Action Input: [Tool parameters as JSON, or step summary for 'Step Complete']
|
||||
```
|
||||
|
||||
## Step Execution Guidelines
|
||||
|
||||
1. **Stay Focused**: Only work on the current step's goal
|
||||
2. **Be Thorough**: Gather enough information to achieve the goal
|
||||
3. **Document Progress**: Note important findings in your thoughts
|
||||
4. **Know When to Stop**: Complete the step when you have sufficient results
|
||||
5. **Handle Failures**: If a tool fails, try alternatives or note the limitation
|
||||
|
||||
## When to Mark Step Complete
|
||||
|
||||
Mark the step as complete when:
|
||||
- You have achieved the step's goal
|
||||
- You have gathered sufficient information or generated the required output
|
||||
- Further work would be outside the step's scope
|
||||
|
||||
Your step summary should include:
|
||||
- Key results or findings relevant to the step's goal
|
||||
- Tools used and their outputs
|
||||
- Any limitations or issues encountered
|
||||
@@ -1,37 +0,0 @@
|
||||
You are an intelligent AI Agent synthesizing results from multiple execution steps into a comprehensive final output.
|
||||
|
||||
## Your Task
|
||||
|
||||
Review all the results from the completed steps and provide a unified, well-structured response that addresses the original request.
|
||||
|
||||
## Response Guidelines
|
||||
|
||||
Based on the task type, structure your response appropriately:
|
||||
|
||||
**For Root Cause Analysis:**
|
||||
- Summary of the root cause
|
||||
- Supporting evidence from investigation
|
||||
- Impact assessment
|
||||
- Recommended actions
|
||||
|
||||
**For Data Analysis / SQL Generation:**
|
||||
- Query results or generated SQL
|
||||
- Key insights from the data
|
||||
- Any caveats or limitations
|
||||
|
||||
**For Information Synthesis:**
|
||||
- Structured summary of findings
|
||||
- Key insights and patterns
|
||||
- Relevant conclusions
|
||||
|
||||
**For Content Generation:**
|
||||
- Generated content (title, summary, etc.)
|
||||
- Alternative options if applicable
|
||||
|
||||
## Synthesis Principles
|
||||
|
||||
1. **Integrate Results**: Combine findings from all completed steps coherently
|
||||
2. **Prioritize Relevance**: Focus on the most important information
|
||||
3. **Be Structured**: Organize output in a clear, logical format
|
||||
4. **Be Concise**: Avoid unnecessary verbosity while ensuring completeness
|
||||
5. **Address the Request**: Ensure the final output directly answers the original task
|
||||
@@ -1,7 +0,0 @@
|
||||
## Alert Information
|
||||
|
||||
{{.AlertContent}}
|
||||
|
||||
## Analysis Request
|
||||
|
||||
Please analyze this alert and identify the root cause. Provide evidence-based conclusions and actionable recommendations.
|
||||
573
aiagent/skill.go
573
aiagent/skill.go
@@ -1,573 +0,0 @@
|
||||
package aiagent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/toolkits/pkg/logger"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
// SkillFileName 技能主文件名
|
||||
SkillFileName = "SKILL.md"
|
||||
// SkillToolsDir 技能工具目录名
|
||||
SkillToolsDir = "skill_tools"
|
||||
|
||||
// 默认配置
|
||||
DefaultMaxSkills = 2
|
||||
)
|
||||
|
||||
// SkillConfig 技能配置(在 AIAgentConfig 中使用)
|
||||
// 技能目录路径通过全局配置 Plus.AIAgentSkillsPath 设置
|
||||
type SkillConfig struct {
|
||||
// 技能选择配置(优先级:SkillNames > LLM 选择 > DefaultSkills)
|
||||
AutoSelect bool `json:"auto_select,omitempty"` // 是否让 LLM 自动选择技能(默认 true)
|
||||
SkillNames []string `json:"skill_names,omitempty"` // 直接指定技能名列表(手动模式)
|
||||
MaxSkills int `json:"max_skills,omitempty"` // LLM 最多选择几个技能(默认 2)
|
||||
DefaultSkills []string `json:"default_skills,omitempty"` // 默认技能列表(LLM 无法选择时使用)
|
||||
}
|
||||
|
||||
// SkillMetadata 技能元数据(Level 1 - 总是在内存中)
|
||||
type SkillMetadata struct {
|
||||
// 核心字段(与 Anthropic 官方一致)
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
|
||||
// 可选扩展字段
|
||||
RecommendedTools []string `yaml:"recommended_tools,omitempty" json:"recommended_tools,omitempty"`
|
||||
BuiltinTools []string `yaml:"builtin_tools,omitempty" json:"builtin_tools,omitempty"` // 内置工具列表
|
||||
|
||||
// 内部字段
|
||||
Path string `json:"-"` // 技能目录路径
|
||||
LoadedAt time.Time `json:"-"` // 加载时间
|
||||
}
|
||||
|
||||
// SkillContent 技能内容(Level 2 - 匹配时加载)
|
||||
type SkillContent struct {
|
||||
Metadata *SkillMetadata `json:"metadata"`
|
||||
MainContent string `json:"main_content"` // SKILL.md 正文
|
||||
}
|
||||
|
||||
// SkillTool Skill 专用工具(Level 3 - 按需加载)
|
||||
type SkillTool struct {
|
||||
Name string `yaml:"name" json:"name"` // 工具名称
|
||||
Type string `yaml:"type" json:"type"` // 处理器类型:annotation_qd, script, callback 等
|
||||
Description string `yaml:"description" json:"description"` // 工具描述
|
||||
Config map[string]interface{} `yaml:"config" json:"config"` // 处理器配置
|
||||
|
||||
// 参数定义(可选)
|
||||
Parameters []ToolParameter `yaml:"parameters,omitempty" json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// SkillResources 技能扩展资源(Level 3 - 按需加载)
|
||||
type SkillResources struct {
|
||||
SkillTools map[string]*SkillTool `json:"skill_tools"` // 工具名 -> 工具定义
|
||||
References map[string]string `json:"references"` // 引用文件内容
|
||||
}
|
||||
|
||||
// SkillRegistry 技能注册表
|
||||
type SkillRegistry struct {
|
||||
skillsPath string // 技能目录路径
|
||||
skills map[string]*SkillMetadata // name -> metadata
|
||||
contentCache map[string]*SkillContent // name -> content (LRU cache)
|
||||
toolsCache map[string]map[string]*SkillTool // skillName -> toolName -> tool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSkillRegistry 创建技能注册表
|
||||
func NewSkillRegistry(skillsPath string) *SkillRegistry {
|
||||
registry := &SkillRegistry{
|
||||
skillsPath: skillsPath,
|
||||
skills: make(map[string]*SkillMetadata),
|
||||
contentCache: make(map[string]*SkillContent),
|
||||
toolsCache: make(map[string]map[string]*SkillTool),
|
||||
}
|
||||
|
||||
// 初始加载所有技能元数据
|
||||
if err := registry.loadAllMetadata(); err != nil {
|
||||
logger.Warningf("Failed to load skill metadata: %v", err)
|
||||
}
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
// loadAllMetadata 加载所有技能的元数据(Level 1)
|
||||
func (r *SkillRegistry) loadAllMetadata() error {
|
||||
if r.skillsPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查目录是否存在
|
||||
if _, err := os.Stat(r.skillsPath); os.IsNotExist(err) {
|
||||
logger.Debugf("Skills directory does not exist: %s", r.skillsPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 遍历技能目录
|
||||
entries, err := os.ReadDir(r.skillsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read skills directory: %v", err)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(r.skillsPath, entry.Name())
|
||||
skillFile := filepath.Join(skillPath, SkillFileName)
|
||||
|
||||
// 检查 SKILL.md 是否存在
|
||||
if _, err := os.Stat(skillFile); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 加载元数据
|
||||
metadata, err := r.loadMetadataFromFile(skillFile)
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to load skill metadata from %s: %v", skillFile, err)
|
||||
continue
|
||||
}
|
||||
|
||||
metadata.Path = skillPath
|
||||
metadata.LoadedAt = time.Now()
|
||||
r.skills[metadata.Name] = metadata
|
||||
|
||||
logger.Debugf("Loaded skill metadata: %s from %s", metadata.Name, skillPath)
|
||||
}
|
||||
|
||||
logger.Infof("Loaded %d skills from %s", len(r.skills), r.skillsPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 从 SKILL.md 文件加载元数据
|
||||
func (r *SkillRegistry) loadMetadataFromFile(filePath string) (*SkillMetadata, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 解析 YAML frontmatter
|
||||
scanner := bufio.NewScanner(file)
|
||||
var inFrontmatter bool
|
||||
var frontmatterLines []string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if line == "---" {
|
||||
if !inFrontmatter {
|
||||
inFrontmatter = true
|
||||
continue
|
||||
} else {
|
||||
// frontmatter 结束
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if inFrontmatter {
|
||||
frontmatterLines = append(frontmatterLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan file: %v", err)
|
||||
}
|
||||
|
||||
if len(frontmatterLines) == 0 {
|
||||
return nil, fmt.Errorf("no frontmatter found in %s", filePath)
|
||||
}
|
||||
|
||||
// 解析 YAML
|
||||
frontmatter := strings.Join(frontmatterLines, "\n")
|
||||
var metadata SkillMetadata
|
||||
if err := yaml.Unmarshal([]byte(frontmatter), &metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse frontmatter: %v", err)
|
||||
}
|
||||
|
||||
if metadata.Name == "" {
|
||||
return nil, fmt.Errorf("skill name is required in frontmatter")
|
||||
}
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// GetByName 根据名称获取技能元数据
|
||||
func (r *SkillRegistry) GetByName(name string) *SkillMetadata {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
return r.skills[name]
|
||||
}
|
||||
|
||||
// ListAll 列出所有技能元数据
|
||||
func (r *SkillRegistry) ListAll() []*SkillMetadata {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
result := make([]*SkillMetadata, 0, len(r.skills))
|
||||
for _, metadata := range r.skills {
|
||||
result = append(result, metadata)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LoadContent 加载技能内容(Level 2)
|
||||
func (r *SkillRegistry) LoadContent(metadata *SkillMetadata) (*SkillContent, error) {
|
||||
if metadata == nil {
|
||||
return nil, fmt.Errorf("metadata is nil")
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
r.mu.RLock()
|
||||
if cached, ok := r.contentCache[metadata.Name]; ok {
|
||||
r.mu.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// 加载内容
|
||||
skillFile := filepath.Join(metadata.Path, SkillFileName)
|
||||
content, err := r.loadContentFromFile(skillFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
skillContent := &SkillContent{
|
||||
Metadata: metadata,
|
||||
MainContent: content,
|
||||
}
|
||||
|
||||
// 缓存
|
||||
r.mu.Lock()
|
||||
r.contentCache[metadata.Name] = skillContent
|
||||
r.mu.Unlock()
|
||||
|
||||
return skillContent, nil
|
||||
}
|
||||
|
||||
// loadContentFromFile 从 SKILL.md 文件加载正文内容
|
||||
func (r *SkillRegistry) loadContentFromFile(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var inFrontmatter bool
|
||||
var frontmatterEnded bool
|
||||
var contentLines []string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
if !inFrontmatter {
|
||||
inFrontmatter = true
|
||||
continue
|
||||
} else {
|
||||
frontmatterEnded = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if frontmatterEnded {
|
||||
contentLines = append(contentLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("failed to scan file: %v", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.Join(contentLines, "\n")), nil
|
||||
}
|
||||
|
||||
// LoadSkillTool 加载单个 skill_tool(Level 3 - 完整配置)
|
||||
func (r *SkillRegistry) LoadSkillTool(skillName, toolName string) (*SkillTool, error) {
|
||||
// 检查缓存
|
||||
r.mu.RLock()
|
||||
if skillTools, ok := r.toolsCache[skillName]; ok {
|
||||
if tool, ok := skillTools[toolName]; ok {
|
||||
r.mu.RUnlock()
|
||||
return tool, nil
|
||||
}
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// 获取技能元数据
|
||||
metadata := r.GetByName(skillName)
|
||||
if metadata == nil {
|
||||
return nil, fmt.Errorf("skill '%s' not found", skillName)
|
||||
}
|
||||
|
||||
// 加载工具
|
||||
toolFile := filepath.Join(metadata.Path, SkillToolsDir, toolName+".yaml")
|
||||
tool, err := r.loadToolFromFile(toolFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 缓存
|
||||
r.mu.Lock()
|
||||
if r.toolsCache[skillName] == nil {
|
||||
r.toolsCache[skillName] = make(map[string]*SkillTool)
|
||||
}
|
||||
r.toolsCache[skillName][toolName] = tool
|
||||
r.mu.Unlock()
|
||||
|
||||
return tool, nil
|
||||
}
|
||||
|
||||
// LoadSkillToolDescription 加载 skill_tool 的描述信息(轻量级,只读取 name 和 description)
|
||||
func (r *SkillRegistry) LoadSkillToolDescription(skillName, toolName string) (string, error) {
|
||||
// 检查缓存 - 如果已经加载了完整工具,直接返回 description
|
||||
r.mu.RLock()
|
||||
if skillTools, ok := r.toolsCache[skillName]; ok {
|
||||
if tool, ok := skillTools[toolName]; ok {
|
||||
r.mu.RUnlock()
|
||||
return tool.Description, nil
|
||||
}
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// 获取技能元数据
|
||||
metadata := r.GetByName(skillName)
|
||||
if metadata == nil {
|
||||
return "", fmt.Errorf("skill '%s' not found", skillName)
|
||||
}
|
||||
|
||||
// 只加载 description(不缓存完整工具,保持延迟加载特性)
|
||||
toolFile := filepath.Join(metadata.Path, SkillToolsDir, toolName+".yaml")
|
||||
tool, err := r.loadToolFromFile(toolFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tool.Description, nil
|
||||
}
|
||||
|
||||
// LoadAllSkillToolDescriptions 加载技能目录下所有 skill_tools 的描述
|
||||
func (r *SkillRegistry) LoadAllSkillToolDescriptions(skillName string) (map[string]string, error) {
|
||||
metadata := r.GetByName(skillName)
|
||||
if metadata == nil {
|
||||
return nil, fmt.Errorf("skill '%s' not found", skillName)
|
||||
}
|
||||
|
||||
toolsDir := filepath.Join(metadata.Path, SkillToolsDir)
|
||||
|
||||
// 检查目录是否存在
|
||||
if _, err := os.Stat(toolsDir); os.IsNotExist(err) {
|
||||
return make(map[string]string), nil
|
||||
}
|
||||
|
||||
// 遍历 skill_tools 目录
|
||||
entries, err := os.ReadDir(toolsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read skill_tools directory: %v", err)
|
||||
}
|
||||
|
||||
descriptions := make(map[string]string)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 只处理 .yaml 文件
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".yml") {
|
||||
continue
|
||||
}
|
||||
|
||||
toolFile := filepath.Join(toolsDir, name)
|
||||
tool, err := r.loadToolFromFile(toolFile)
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to load skill tool %s: %v", toolFile, err)
|
||||
continue
|
||||
}
|
||||
|
||||
descriptions[tool.Name] = tool.Description
|
||||
}
|
||||
|
||||
return descriptions, nil
|
||||
}
|
||||
|
||||
// loadToolFromFile 从文件加载工具定义
|
||||
func (r *SkillRegistry) loadToolFromFile(filePath string) (*SkillTool, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read tool file: %v", err)
|
||||
}
|
||||
|
||||
var tool SkillTool
|
||||
if err := yaml.Unmarshal(data, &tool); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse tool file: %v", err)
|
||||
}
|
||||
|
||||
return &tool, nil
|
||||
}
|
||||
|
||||
// LoadReference 加载引用文件(Level 3)
|
||||
func (r *SkillRegistry) LoadReference(metadata *SkillMetadata, refName string) (string, error) {
|
||||
if metadata == nil {
|
||||
return "", fmt.Errorf("metadata is nil")
|
||||
}
|
||||
|
||||
refFile := filepath.Join(metadata.Path, refName)
|
||||
data, err := os.ReadFile(refFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read reference file: %v", err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Reload 重新加载所有技能元数据
|
||||
func (r *SkillRegistry) Reload() error {
|
||||
r.mu.Lock()
|
||||
r.skills = make(map[string]*SkillMetadata)
|
||||
r.contentCache = make(map[string]*SkillContent)
|
||||
r.toolsCache = make(map[string]map[string]*SkillTool)
|
||||
r.mu.Unlock()
|
||||
|
||||
return r.loadAllMetadata()
|
||||
}
|
||||
|
||||
// SkillSelector 技能选择器接口
|
||||
type SkillSelector interface {
|
||||
// SelectMultiple 让 LLM 根据任务内容选择最合适的技能(可多选)
|
||||
SelectMultiple(ctx context.Context, taskContext string, availableSkills []*SkillMetadata, maxSkills int) ([]*SkillMetadata, error)
|
||||
}
|
||||
|
||||
// LLMSkillSelector 基于 LLM 的技能选择器
|
||||
type LLMSkillSelector struct {
|
||||
llmCaller func(ctx context.Context, messages []ChatMessage) (string, error)
|
||||
}
|
||||
|
||||
// NewLLMSkillSelector 创建 LLM 技能选择器
|
||||
func NewLLMSkillSelector(llmCaller func(ctx context.Context, messages []ChatMessage) (string, error)) *LLMSkillSelector {
|
||||
return &LLMSkillSelector{
|
||||
llmCaller: llmCaller,
|
||||
}
|
||||
}
|
||||
|
||||
// SelectMultiple 使用 LLM 选择技能
|
||||
func (s *LLMSkillSelector) SelectMultiple(ctx context.Context, taskContext string, availableSkills []*SkillMetadata, maxSkills int) ([]*SkillMetadata, error) {
|
||||
if len(availableSkills) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if maxSkills <= 0 {
|
||||
maxSkills = DefaultMaxSkills
|
||||
}
|
||||
|
||||
// 构建提示词
|
||||
systemPrompt := s.buildSelectionPrompt(availableSkills, maxSkills)
|
||||
|
||||
messages := []ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: taskContext},
|
||||
}
|
||||
|
||||
// 调用 LLM
|
||||
response, err := s.llmCaller(ctx, messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM call failed: %v", err)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
selectedNames := s.parseSelectionResponse(response)
|
||||
if len(selectedNames) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 限制数量
|
||||
if len(selectedNames) > maxSkills {
|
||||
selectedNames = selectedNames[:maxSkills]
|
||||
}
|
||||
|
||||
// 转换为 SkillMetadata
|
||||
skillMap := make(map[string]*SkillMetadata)
|
||||
for _, skill := range availableSkills {
|
||||
skillMap[skill.Name] = skill
|
||||
}
|
||||
|
||||
var result []*SkillMetadata
|
||||
for _, name := range selectedNames {
|
||||
if skill, ok := skillMap[name]; ok {
|
||||
result = append(result, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildSelectionPrompt 构建技能选择提示词
|
||||
func (s *LLMSkillSelector) buildSelectionPrompt(availableSkills []*SkillMetadata, maxSkills int) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf(`你是一个技能选择器。根据以下任务上下文,选择最合适的技能(可选择 1-%d 个)。
|
||||
|
||||
## 可用技能
|
||||
|
||||
`, maxSkills))
|
||||
|
||||
for i, skill := range availableSkills {
|
||||
sb.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, skill.Name))
|
||||
sb.WriteString(fmt.Sprintf(" %s\n\n", skill.Description))
|
||||
}
|
||||
|
||||
sb.WriteString(`## 输出格式
|
||||
|
||||
请以 JSON 数组格式返回选中的技能名称,例如:
|
||||
` + "```json\n" + `["skill-name-1", "skill-name-2"]
|
||||
` + "```" + `
|
||||
|
||||
## 选择原则
|
||||
|
||||
1. 选择与任务最相关的技能
|
||||
2. 如果任务涉及多个领域,可以选择多个技能
|
||||
3. 优先选择更具体、更专业的技能
|
||||
4. 如果没有合适的技能,返回空数组 []
|
||||
|
||||
请返回技能名称数组:`)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// parseSelectionResponse 解析 LLM 的选择响应
|
||||
func (s *LLMSkillSelector) parseSelectionResponse(response string) []string {
|
||||
// 尝试从 JSON 代码块中提取
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// 查找 JSON 数组
|
||||
start := strings.Index(response, "[")
|
||||
end := strings.LastIndex(response, "]")
|
||||
|
||||
if start < 0 || end <= start {
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonStr := response[start : end+1]
|
||||
|
||||
var skillNames []string
|
||||
if err := json.Unmarshal([]byte(jsonStr), &skillNames); err != nil {
|
||||
logger.Warningf("Failed to parse skill selection response: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return skillNames
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/alert/aconf"
|
||||
"github.com/ccfos/nightingale/v6/alert/common"
|
||||
"github.com/ccfos/nightingale/v6/alert/queue"
|
||||
"github.com/ccfos/nightingale/v6/memsto"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
@@ -98,12 +99,12 @@ func (e *Consumer) consumeOne(event *models.AlertCurEvent) {
|
||||
e.dispatch.Astats.CounterAlertsTotal.WithLabelValues(event.Cluster, eventType, event.GroupName).Inc()
|
||||
|
||||
if err := event.ParseRule("rule_name"); err != nil {
|
||||
logger.Warningf("alert_eval_%d datasource_%d failed to parse rule name: %v", event.RuleId, event.DatasourceId, err)
|
||||
logger.Warningf("ruleid:%d failed to parse rule name: %v", event.RuleId, err)
|
||||
event.RuleName = fmt.Sprintf("failed to parse rule name: %v", err)
|
||||
}
|
||||
|
||||
if err := event.ParseRule("annotations"); err != nil {
|
||||
logger.Warningf("alert_eval_%d datasource_%d failed to parse annotations: %v", event.RuleId, event.DatasourceId, err)
|
||||
logger.Warningf("ruleid:%d failed to parse annotations: %v", event.RuleId, err)
|
||||
event.Annotations = fmt.Sprintf("failed to parse annotations: %v", err)
|
||||
event.AnnotationsJSON["error"] = event.Annotations
|
||||
}
|
||||
@@ -111,7 +112,7 @@ func (e *Consumer) consumeOne(event *models.AlertCurEvent) {
|
||||
e.queryRecoveryVal(event)
|
||||
|
||||
if err := event.ParseRule("rule_note"); err != nil {
|
||||
logger.Warningf("alert_eval_%d datasource_%d failed to parse rule note: %v", event.RuleId, event.DatasourceId, err)
|
||||
logger.Warningf("ruleid:%d failed to parse rule note: %v", event.RuleId, err)
|
||||
event.RuleNote = fmt.Sprintf("failed to parse rule note: %v", err)
|
||||
}
|
||||
|
||||
@@ -156,12 +157,12 @@ func (e *Consumer) queryRecoveryVal(event *models.AlertCurEvent) {
|
||||
|
||||
promql = strings.TrimSpace(promql)
|
||||
if promql == "" {
|
||||
logger.Warningf("alert_eval_%d datasource_%d promql is blank", event.RuleId, event.DatasourceId)
|
||||
logger.Warningf("rule_eval:%s promql is blank", getKey(event))
|
||||
return
|
||||
}
|
||||
|
||||
if e.promClients.IsNil(event.DatasourceId) {
|
||||
logger.Warningf("alert_eval_%d datasource_%d error reader client is nil", event.RuleId, event.DatasourceId)
|
||||
logger.Warningf("rule_eval:%s error reader client is nil", getKey(event))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,7 +171,7 @@ func (e *Consumer) queryRecoveryVal(event *models.AlertCurEvent) {
|
||||
var warnings promsdk.Warnings
|
||||
value, warnings, err := readerClient.Query(e.ctx.Ctx, promql, time.Now())
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d promql:%s, error:%v", event.RuleId, event.DatasourceId, promql, err)
|
||||
logger.Errorf("rule_eval:%s promql:%s, error:%v", getKey(event), promql, err)
|
||||
event.AnnotationsJSON["recovery_promql_error"] = fmt.Sprintf("promql:%s error:%v", promql, err)
|
||||
|
||||
b, err := json.Marshal(event.AnnotationsJSON)
|
||||
@@ -184,12 +185,12 @@ func (e *Consumer) queryRecoveryVal(event *models.AlertCurEvent) {
|
||||
}
|
||||
|
||||
if len(warnings) > 0 {
|
||||
logger.Errorf("alert_eval_%d datasource_%d promql:%s, warnings:%v", event.RuleId, event.DatasourceId, promql, warnings)
|
||||
logger.Errorf("rule_eval:%s promql:%s, warnings:%v", getKey(event), promql, warnings)
|
||||
}
|
||||
|
||||
anomalyPoints := models.ConvertAnomalyPoints(value)
|
||||
if len(anomalyPoints) == 0 {
|
||||
logger.Warningf("alert_eval_%d datasource_%d promql:%s, result is empty", event.RuleId, event.DatasourceId, promql)
|
||||
logger.Warningf("rule_eval:%s promql:%s, result is empty", getKey(event), promql)
|
||||
event.AnnotationsJSON["recovery_promql_error"] = fmt.Sprintf("promql:%s error:%s", promql, "result is empty")
|
||||
} else {
|
||||
event.AnnotationsJSON["recovery_value"] = fmt.Sprintf("%v", anomalyPoints[0].Value)
|
||||
@@ -204,3 +205,6 @@ func (e *Consumer) queryRecoveryVal(event *models.AlertCurEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
func getKey(event *models.AlertCurEvent) string {
|
||||
return common.RuleKey(event.DatasourceId, event.RuleId)
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ func LogEvent(event *models.AlertCurEvent, location string, err ...error) {
|
||||
}
|
||||
|
||||
logger.Infof(
|
||||
"alert_eval_%d event(%s %s) %s: sub_id:%d notify_rule_ids:%v cluster:%s %v%s@%d last_eval_time:%d %s",
|
||||
event.RuleId,
|
||||
"event(%s %s) %s: rule_id=%d sub_id:%d notify_rule_ids:%v cluster:%s %v%s@%d last_eval_time:%d %s",
|
||||
event.Hash,
|
||||
status,
|
||||
location,
|
||||
event.RuleId,
|
||||
event.SubRuleId,
|
||||
event.NotifyRuleIds,
|
||||
event.Cluster,
|
||||
|
||||
@@ -101,17 +101,17 @@ func (s *Scheduler) syncAlertRules() {
|
||||
}
|
||||
ds := s.datasourceCache.GetById(dsId)
|
||||
if ds == nil {
|
||||
logger.Debugf("alert_eval_%d datasource %d not found", rule.Id, dsId)
|
||||
logger.Debugf("datasource %d not found", dsId)
|
||||
continue
|
||||
}
|
||||
|
||||
if ds.PluginType != ruleType {
|
||||
logger.Debugf("alert_eval_%d datasource %d category is %s not %s", rule.Id, dsId, ds.PluginType, ruleType)
|
||||
logger.Debugf("datasource %d category is %s not %s", dsId, ds.PluginType, ruleType)
|
||||
continue
|
||||
}
|
||||
|
||||
if ds.Status != "enabled" {
|
||||
logger.Debugf("alert_eval_%d datasource %d status is %s", rule.Id, dsId, ds.Status)
|
||||
logger.Debugf("datasource %d status is %s", dsId, ds.Status)
|
||||
continue
|
||||
}
|
||||
processor := process.NewProcessor(s.aconf.Heartbeat.EngineName, rule, dsId, s.alertRuleCache, s.targetCache, s.targetsOfAlertRuleCache, s.busiGroupCache, s.alertMuteCache, s.datasourceCache, s.ctx, s.stats)
|
||||
@@ -134,12 +134,12 @@ func (s *Scheduler) syncAlertRules() {
|
||||
for _, dsId := range dsIds {
|
||||
ds := s.datasourceCache.GetById(dsId)
|
||||
if ds == nil {
|
||||
logger.Debugf("alert_eval_%d datasource %d not found", rule.Id, dsId)
|
||||
logger.Debugf("datasource %d not found", dsId)
|
||||
continue
|
||||
}
|
||||
|
||||
if ds.Status != "enabled" {
|
||||
logger.Debugf("alert_eval_%d datasource %d status is %s", rule.Id, dsId, ds.Status)
|
||||
logger.Debugf("datasource %d status is %s", dsId, ds.Status)
|
||||
continue
|
||||
}
|
||||
processor := process.NewProcessor(s.aconf.Heartbeat.EngineName, rule, dsId, s.alertRuleCache, s.targetCache, s.targetsOfAlertRuleCache, s.busiGroupCache, s.alertMuteCache, s.datasourceCache, s.ctx, s.stats)
|
||||
|
||||
@@ -109,7 +109,7 @@ func NewAlertRuleWorker(rule *models.AlertRule, datasourceId int64, Processor *p
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d add cron pattern error: %v", arw.Rule.Id, arw.DatasourceId, err)
|
||||
logger.Errorf("alert rule %s add cron pattern error: %v", arw.Key(), err)
|
||||
}
|
||||
|
||||
Processor.ScheduleEntry = arw.Scheduler.Entry(entryID)
|
||||
@@ -152,9 +152,9 @@ func (arw *AlertRuleWorker) Eval() {
|
||||
|
||||
defer func() {
|
||||
if len(message) == 0 {
|
||||
logger.Infof("alert_eval_%d datasource_%d finished, duration:%v", arw.Rule.Id, arw.DatasourceId, time.Since(begin))
|
||||
logger.Infof("rule_eval:%s finished, duration:%v", arw.Key(), time.Since(begin))
|
||||
} else {
|
||||
logger.Warningf("alert_eval_%d datasource_%d finished, duration:%v, message:%s", arw.Rule.Id, arw.DatasourceId, time.Since(begin), message)
|
||||
logger.Warningf("rule_eval:%s finished, duration:%v, message:%s", arw.Key(), time.Since(begin), message)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -236,7 +236,7 @@ func (arw *AlertRuleWorker) Eval() {
|
||||
}
|
||||
|
||||
func (arw *AlertRuleWorker) Stop() {
|
||||
logger.Infof("alert_eval_%d datasource_%d stopped", arw.Rule.Id, arw.DatasourceId)
|
||||
logger.Infof("rule_eval:%s stopped", arw.Key())
|
||||
close(arw.Quit)
|
||||
c := arw.Scheduler.Stop()
|
||||
<-c.Done()
|
||||
@@ -252,7 +252,7 @@ func (arw *AlertRuleWorker) GetPromAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
|
||||
var rule *models.PromRuleConfig
|
||||
if err := json.Unmarshal([]byte(ruleConfig), &rule); err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d rule_config:%s, error:%v", arw.Rule.Id, arw.DatasourceId, ruleConfig, err)
|
||||
logger.Errorf("rule_eval:%s rule_config:%s, error:%v", arw.Key(), ruleConfig, err)
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), GET_RULE_CONFIG, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
fmt.Sprintf("%v", arw.Rule.Id),
|
||||
@@ -263,7 +263,7 @@ func (arw *AlertRuleWorker) GetPromAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
}
|
||||
|
||||
if rule == nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d rule_config:%s, error:rule is nil", arw.Rule.Id, arw.DatasourceId, ruleConfig)
|
||||
logger.Errorf("rule_eval:%s rule_config:%s, error:rule is nil", arw.Key(), ruleConfig)
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), GET_RULE_CONFIG, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
fmt.Sprintf("%v", arw.Rule.Id),
|
||||
@@ -278,7 +278,7 @@ func (arw *AlertRuleWorker) GetPromAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
readerClient := arw.PromClients.GetCli(arw.DatasourceId)
|
||||
|
||||
if readerClient == nil {
|
||||
logger.Warningf("alert_eval_%d datasource_%d error reader client is nil", arw.Rule.Id, arw.DatasourceId)
|
||||
logger.Warningf("rule_eval:%s error reader client is nil", arw.Key())
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), GET_CLIENT, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
fmt.Sprintf("%v", arw.Rule.Id),
|
||||
@@ -314,13 +314,13 @@ func (arw *AlertRuleWorker) GetPromAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
// 无变量
|
||||
promql := strings.TrimSpace(query.PromQl)
|
||||
if promql == "" {
|
||||
logger.Warningf("alert_eval_%d datasource_%d promql is blank", arw.Rule.Id, arw.DatasourceId)
|
||||
logger.Warningf("rule_eval:%s promql is blank", arw.Key())
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), CHECK_QUERY, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
continue
|
||||
}
|
||||
|
||||
if arw.PromClients.IsNil(arw.DatasourceId) {
|
||||
logger.Warningf("alert_eval_%d datasource_%d error reader client is nil", arw.Rule.Id, arw.DatasourceId)
|
||||
logger.Warningf("rule_eval:%s error reader client is nil", arw.Key())
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), GET_CLIENT, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
continue
|
||||
}
|
||||
@@ -329,7 +329,7 @@ func (arw *AlertRuleWorker) GetPromAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
arw.Processor.Stats.CounterQueryDataTotal.WithLabelValues(fmt.Sprintf("%d", arw.DatasourceId), fmt.Sprintf("%d", arw.Rule.Id)).Inc()
|
||||
value, warnings, err := readerClient.Query(context.Background(), promql, time.Now())
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d promql:%s, error:%v", arw.Rule.Id, arw.DatasourceId, promql, err)
|
||||
logger.Errorf("rule_eval:%s promql:%s, error:%v", arw.Key(), promql, err)
|
||||
arw.Processor.Stats.CounterQueryDataErrorTotal.WithLabelValues(fmt.Sprintf("%d", arw.DatasourceId)).Inc()
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), QUERY_DATA, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
@@ -341,12 +341,12 @@ func (arw *AlertRuleWorker) GetPromAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
}
|
||||
|
||||
if len(warnings) > 0 {
|
||||
logger.Errorf("alert_eval_%d datasource_%d promql:%s, warnings:%v", arw.Rule.Id, arw.DatasourceId, promql, warnings)
|
||||
logger.Errorf("rule_eval:%s promql:%s, warnings:%v", arw.Key(), promql, warnings)
|
||||
arw.Processor.Stats.CounterQueryDataErrorTotal.WithLabelValues(fmt.Sprintf("%d", arw.DatasourceId)).Inc()
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), QUERY_DATA, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
}
|
||||
|
||||
logger.Infof("alert_eval_%d datasource_%d query:%+v, value:%v", arw.Rule.Id, arw.DatasourceId, query, value)
|
||||
logger.Infof("rule_eval:%s query:%+v, value:%v", arw.Key(), query, value)
|
||||
points := models.ConvertAnomalyPoints(value)
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
fmt.Sprintf("%v", arw.Rule.Id),
|
||||
@@ -440,14 +440,14 @@ func (arw *AlertRuleWorker) VarFillingAfterQuery(query models.PromQuery, readerC
|
||||
arw.Processor.Stats.CounterQueryDataTotal.WithLabelValues(fmt.Sprintf("%d", arw.DatasourceId), fmt.Sprintf("%d", arw.Rule.Id)).Inc()
|
||||
value, _, err := readerClient.Query(context.Background(), curQuery, time.Now())
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d promql:%s, error:%v", arw.Rule.Id, arw.DatasourceId, curQuery, err)
|
||||
logger.Errorf("rule_eval:%s, promql:%s, error:%v", arw.Key(), curQuery, err)
|
||||
continue
|
||||
}
|
||||
seqVals := getSamples(value)
|
||||
// 得到参数变量的所有组合
|
||||
paramPermutation, err := arw.getParamPermutation(param, ParamKeys, varToLabel, query.PromQl, readerClient)
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d paramPermutation error:%v", arw.Rule.Id, arw.DatasourceId, err)
|
||||
logger.Errorf("rule_eval:%s, paramPermutation error:%v", arw.Key(), err)
|
||||
continue
|
||||
}
|
||||
// 判断哪些参数值符合条件
|
||||
@@ -580,14 +580,14 @@ func (arw *AlertRuleWorker) getParamPermutation(paramVal map[string]models.Param
|
||||
case "host":
|
||||
hostIdents, err := arw.getHostIdents(paramQuery)
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d fail to get host idents, error:%v", arw.Rule.Id, arw.DatasourceId, err)
|
||||
logger.Errorf("rule_eval:%s, fail to get host idents, error:%v", arw.Key(), err)
|
||||
break
|
||||
}
|
||||
params = hostIdents
|
||||
case "device":
|
||||
deviceIdents, err := arw.getDeviceIdents(paramQuery)
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d fail to get device idents, error:%v", arw.Rule.Id, arw.DatasourceId, err)
|
||||
logger.Errorf("rule_eval:%s, fail to get device idents, error:%v", arw.Key(), err)
|
||||
break
|
||||
}
|
||||
params = deviceIdents
|
||||
@@ -596,12 +596,12 @@ func (arw *AlertRuleWorker) getParamPermutation(paramVal map[string]models.Param
|
||||
var query []string
|
||||
err := json.Unmarshal(q, &query)
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d query:%s fail to unmarshalling into string slice, error:%v", arw.Rule.Id, arw.DatasourceId, paramQuery.Query, err)
|
||||
logger.Errorf("query:%s fail to unmarshalling into string slice, error:%v", paramQuery.Query, err)
|
||||
}
|
||||
if len(query) == 0 {
|
||||
paramsKeyAllLabel, err := getParamKeyAllLabel(varToLabel[paramKey], originPromql, readerClient, arw.DatasourceId, arw.Rule.Id, arw.Processor.Stats)
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d fail to getParamKeyAllLabel, error:%v query:%s", arw.Rule.Id, arw.DatasourceId, err, paramQuery.Query)
|
||||
logger.Errorf("rule_eval:%s, fail to getParamKeyAllLabel, error:%v query:%s", arw.Key(), err, paramQuery.Query)
|
||||
}
|
||||
params = paramsKeyAllLabel
|
||||
} else {
|
||||
@@ -615,7 +615,7 @@ func (arw *AlertRuleWorker) getParamPermutation(paramVal map[string]models.Param
|
||||
return nil, fmt.Errorf("param key: %s, params is empty", paramKey)
|
||||
}
|
||||
|
||||
logger.Infof("alert_eval_%d datasource_%d paramKey: %s, params: %v", arw.Rule.Id, arw.DatasourceId, paramKey, params)
|
||||
logger.Infof("rule_eval:%s paramKey: %s, params: %v", arw.Key(), paramKey, params)
|
||||
paramMap[paramKey] = params
|
||||
}
|
||||
|
||||
@@ -766,7 +766,7 @@ func (arw *AlertRuleWorker) GetHostAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
|
||||
var rule *models.HostRuleConfig
|
||||
if err := json.Unmarshal([]byte(ruleConfig), &rule); err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d rule_config:%s, error:%v", arw.Rule.Id, arw.DatasourceId, ruleConfig, err)
|
||||
logger.Errorf("rule_eval:%s rule_config:%s, error:%v", arw.Key(), ruleConfig, err)
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), GET_RULE_CONFIG, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
fmt.Sprintf("%v", arw.Rule.Id),
|
||||
@@ -777,7 +777,7 @@ func (arw *AlertRuleWorker) GetHostAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
}
|
||||
|
||||
if rule == nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d rule_config:%s, error:rule is nil", arw.Rule.Id, arw.DatasourceId, ruleConfig)
|
||||
logger.Errorf("rule_eval:%s rule_config:%s, error:rule is nil", arw.Key(), ruleConfig)
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), GET_RULE_CONFIG, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
fmt.Sprintf("%v", arw.Rule.Id),
|
||||
@@ -800,7 +800,7 @@ func (arw *AlertRuleWorker) GetHostAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
// 如果是中心节点, 将不再上报数据的主机 engineName 为空的机器,也加入到 targets 中
|
||||
missEngineIdents, exists = arw.Processor.TargetsOfAlertRuleCache.Get("", arw.Rule.Id)
|
||||
if !exists {
|
||||
logger.Debugf("alert_eval_%d datasource_%d targets not found engineName:%s", arw.Rule.Id, arw.DatasourceId, arw.Processor.EngineName)
|
||||
logger.Debugf("rule_eval:%s targets not found engineName:%s", arw.Key(), arw.Processor.EngineName)
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), QUERY_DATA, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
}
|
||||
}
|
||||
@@ -808,7 +808,7 @@ func (arw *AlertRuleWorker) GetHostAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
|
||||
engineIdents, exists = arw.Processor.TargetsOfAlertRuleCache.Get(arw.Processor.EngineName, arw.Rule.Id)
|
||||
if !exists {
|
||||
logger.Warningf("alert_eval_%d datasource_%d targets not found engineName:%s", arw.Rule.Id, arw.DatasourceId, arw.Processor.EngineName)
|
||||
logger.Warningf("rule_eval:%s targets not found engineName:%s", arw.Key(), arw.Processor.EngineName)
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), QUERY_DATA, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
}
|
||||
idents = append(idents, engineIdents...)
|
||||
@@ -835,7 +835,7 @@ func (arw *AlertRuleWorker) GetHostAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
"",
|
||||
).Set(float64(len(missTargets)))
|
||||
|
||||
logger.Debugf("alert_eval_%d datasource_%d missTargets:%v", arw.Rule.Id, arw.DatasourceId, missTargets)
|
||||
logger.Debugf("rule_eval:%s missTargets:%v", arw.Key(), missTargets)
|
||||
targets := arw.Processor.TargetCache.Gets(missTargets)
|
||||
for _, target := range targets {
|
||||
m := make(map[string]string)
|
||||
@@ -854,7 +854,7 @@ func (arw *AlertRuleWorker) GetHostAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
fmt.Sprintf("%v", arw.Processor.DatasourceId()),
|
||||
"",
|
||||
).Set(0)
|
||||
logger.Warningf("alert_eval_%d datasource_%d targets not found", arw.Rule.Id, arw.DatasourceId)
|
||||
logger.Warningf("rule_eval:%s targets not found", arw.Key())
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), QUERY_DATA, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
continue
|
||||
}
|
||||
@@ -885,7 +885,7 @@ func (arw *AlertRuleWorker) GetHostAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debugf("alert_eval_%d datasource_%d offsetIdents:%v", arw.Rule.Id, arw.DatasourceId, offsetIdents)
|
||||
logger.Debugf("rule_eval:%s offsetIdents:%v", arw.Key(), offsetIdents)
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
fmt.Sprintf("%v", arw.Rule.Id),
|
||||
fmt.Sprintf("%v", arw.Processor.DatasourceId()),
|
||||
@@ -912,7 +912,7 @@ func (arw *AlertRuleWorker) GetHostAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
fmt.Sprintf("%v", arw.Processor.DatasourceId()),
|
||||
"",
|
||||
).Set(0)
|
||||
logger.Warningf("alert_eval_%d datasource_%d targets not found", arw.Rule.Id, arw.DatasourceId)
|
||||
logger.Warningf("rule_eval:%s targets not found", arw.Key())
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), QUERY_DATA, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
continue
|
||||
}
|
||||
@@ -924,7 +924,7 @@ func (arw *AlertRuleWorker) GetHostAnomalyPoint(ruleConfig string) ([]models.Ano
|
||||
missTargets = append(missTargets, ident)
|
||||
}
|
||||
}
|
||||
logger.Debugf("alert_eval_%d datasource_%d missTargets:%v", arw.Rule.Id, arw.DatasourceId, missTargets)
|
||||
logger.Debugf("rule_eval:%s missTargets:%v", arw.Key(), missTargets)
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
fmt.Sprintf("%v", arw.Rule.Id),
|
||||
fmt.Sprintf("%v", arw.Processor.DatasourceId()),
|
||||
@@ -1120,7 +1120,7 @@ func ProcessJoins(ruleId int64, trigger models.Trigger, seriesTagIndexes map[str
|
||||
|
||||
// 有 join 条件,按条件依次合并
|
||||
if len(seriesTagIndexes) < len(trigger.Joins)+1 {
|
||||
logger.Errorf("alert_eval_%d queries' count: %d not match join condition's count: %d", ruleId, len(seriesTagIndexes), len(trigger.Joins))
|
||||
logger.Errorf("rule_eval rid:%d queries' count: %d not match join condition's count: %d", ruleId, len(seriesTagIndexes), len(trigger.Joins))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1156,7 +1156,7 @@ func ProcessJoins(ruleId int64, trigger models.Trigger, seriesTagIndexes map[str
|
||||
lastRehashed = exclude(curRehashed, lastRehashed)
|
||||
last = flatten(lastRehashed)
|
||||
default:
|
||||
logger.Warningf("alert_eval_%d join type:%s not support", ruleId, trigger.Joins[i].JoinType)
|
||||
logger.Warningf("rule_eval rid:%d join type:%s not support", ruleId, trigger.Joins[i].JoinType)
|
||||
}
|
||||
}
|
||||
return last
|
||||
@@ -1276,7 +1276,7 @@ func (arw *AlertRuleWorker) VarFillingBeforeQuery(query models.PromQuery, reader
|
||||
// 得到参数变量的所有组合
|
||||
paramPermutation, err := arw.getParamPermutation(param, ParamKeys, varToLabel, query.PromQl, readerClient)
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d paramPermutation error:%v", arw.Rule.Id, arw.DatasourceId, err)
|
||||
logger.Errorf("rule_eval:%s, paramPermutation error:%v", arw.Key(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1304,10 +1304,10 @@ func (arw *AlertRuleWorker) VarFillingBeforeQuery(query models.PromQuery, reader
|
||||
arw.Processor.Stats.CounterQueryDataTotal.WithLabelValues(fmt.Sprintf("%d", arw.DatasourceId), fmt.Sprintf("%d", arw.Rule.Id)).Inc()
|
||||
value, _, err := readerClient.Query(context.Background(), promql, time.Now())
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d promql:%s, error:%v", arw.Rule.Id, arw.DatasourceId, promql, err)
|
||||
logger.Errorf("rule_eval:%s, promql:%s, error:%v", arw.Key(), promql, err)
|
||||
return
|
||||
}
|
||||
logger.Infof("alert_eval_%d datasource_%d promql:%s, value:%+v", arw.Rule.Id, arw.DatasourceId, promql, value)
|
||||
logger.Infof("rule_eval:%s, promql:%s, value:%+v", arw.Key(), promql, value)
|
||||
|
||||
points := models.ConvertAnomalyPoints(value)
|
||||
if len(points) == 0 {
|
||||
@@ -1446,7 +1446,7 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
recoverPoints := []models.AnomalyPoint{}
|
||||
ruleConfig := strings.TrimSpace(rule.RuleConfig)
|
||||
if ruleConfig == "" {
|
||||
logger.Warningf("alert_eval_%d datasource_%d ruleConfig is blank", rule.Id, dsId)
|
||||
logger.Warningf("rule_eval:%d ruleConfig is blank", rule.Id)
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), GET_RULE_CONFIG, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
fmt.Sprintf("%v", arw.Rule.Id),
|
||||
@@ -1454,15 +1454,15 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
"",
|
||||
).Set(0)
|
||||
|
||||
return points, recoverPoints, fmt.Errorf("alert_eval_%d datasource_%d ruleConfig is blank", rule.Id, dsId)
|
||||
return points, recoverPoints, fmt.Errorf("rule_eval:%d ruleConfig is blank", rule.Id)
|
||||
}
|
||||
|
||||
var ruleQuery models.RuleQuery
|
||||
err := json.Unmarshal([]byte(ruleConfig), &ruleQuery)
|
||||
if err != nil {
|
||||
logger.Warningf("alert_eval_%d datasource_%d promql parse error:%s", rule.Id, dsId, err.Error())
|
||||
logger.Warningf("rule_eval:%d promql parse error:%s", rule.Id, err.Error())
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), GET_RULE_CONFIG, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
return points, recoverPoints, fmt.Errorf("alert_eval_%d datasource_%d promql parse error:%s", rule.Id, dsId, err.Error())
|
||||
return points, recoverPoints, fmt.Errorf("rule_eval:%d promql parse error:%s", rule.Id, err.Error())
|
||||
}
|
||||
|
||||
arw.Inhibit = ruleQuery.Inhibit
|
||||
@@ -1474,7 +1474,7 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
|
||||
plug, exists := dscache.DsCache.Get(rule.Cate, dsId)
|
||||
if !exists {
|
||||
logger.Warningf("alert_eval_%d datasource_%d not exists", rule.Id, dsId)
|
||||
logger.Warningf("rule_eval rid:%d datasource:%d not exists", rule.Id, dsId)
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), GET_CLIENT, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
@@ -1483,11 +1483,11 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
fmt.Sprintf("%v", i),
|
||||
).Set(-2)
|
||||
|
||||
return points, recoverPoints, fmt.Errorf("alert_eval_%d datasource_%d not exists", rule.Id, dsId)
|
||||
return points, recoverPoints, fmt.Errorf("rule_eval:%d datasource:%d not exists", rule.Id, dsId)
|
||||
}
|
||||
|
||||
if err = ExecuteQueryTemplate(rule.Cate, query, nil); err != nil {
|
||||
logger.Warningf("alert_eval_%d datasource_%d execute query template error: %v", rule.Id, dsId, err)
|
||||
logger.Warningf("rule_eval rid:%d execute query template error: %v", rule.Id, err)
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), EXEC_TEMPLATE, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
fmt.Sprintf("%v", arw.Rule.Id),
|
||||
@@ -1500,7 +1500,7 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
series, err := plug.QueryData(ctx, query)
|
||||
arw.Processor.Stats.CounterQueryDataTotal.WithLabelValues(fmt.Sprintf("%d", arw.DatasourceId), fmt.Sprintf("%d", rule.Id)).Inc()
|
||||
if err != nil {
|
||||
logger.Warningf("alert_eval_%d datasource_%d query data error: %v", rule.Id, dsId, err)
|
||||
logger.Warningf("rule_eval rid:%d query data error: %v", rule.Id, err)
|
||||
arw.Processor.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", arw.Processor.DatasourceId()), GET_CLIENT, arw.Processor.BusiGroupCache.GetNameByBusiGroupId(arw.Rule.GroupId), fmt.Sprintf("%v", arw.Rule.Id)).Inc()
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
fmt.Sprintf("%v", arw.Rule.Id),
|
||||
@@ -1508,7 +1508,7 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
fmt.Sprintf("%v", i),
|
||||
).Set(-1)
|
||||
|
||||
return points, recoverPoints, fmt.Errorf("alert_eval_%d datasource_%d query data error: %v", rule.Id, dsId, err)
|
||||
return points, recoverPoints, fmt.Errorf("rule_eval:%d query data error: %v", rule.Id, err)
|
||||
}
|
||||
|
||||
arw.Processor.Stats.GaugeQuerySeriesCount.WithLabelValues(
|
||||
@@ -1518,7 +1518,7 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
).Set(float64(len(series)))
|
||||
|
||||
// 此条日志很重要,是告警判断的现场值
|
||||
logger.Infof("alert_eval_%d datasource_%d req:%+v resp:%v", rule.Id, dsId, query, series)
|
||||
logger.Infof("rule_eval rid:%d req:%+v resp:%v", rule.Id, query, series)
|
||||
for i := 0; i < len(series); i++ {
|
||||
seriesHash := hash.GetHash(series[i].Metric, series[i].Ref)
|
||||
tagHash := hash.GetTagHash(series[i].Metric)
|
||||
@@ -1532,7 +1532,7 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
}
|
||||
ref, err := GetQueryRef(query)
|
||||
if err != nil {
|
||||
logger.Warningf("alert_eval_%d datasource_%d query:%+v get ref error:%s", rule.Id, dsId, query, err.Error())
|
||||
logger.Warningf("rule_eval rid:%d query:%+v get ref error:%s", rule.Id, query, err.Error())
|
||||
continue
|
||||
}
|
||||
seriesTagIndexes[ref] = seriesTagIndex
|
||||
@@ -1542,7 +1542,7 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
for _, query := range ruleQuery.Queries {
|
||||
ref, unit, err := GetQueryRefAndUnit(query)
|
||||
if err != nil {
|
||||
logger.Warningf("alert_eval_%d datasource_%d query:%+v get ref and unit error:%s", rule.Id, dsId, query, err.Error())
|
||||
logger.Warningf("rule_eval rid:%d query:%+v get ref and unit error:%s", rule.Id, query, err.Error())
|
||||
continue
|
||||
}
|
||||
unitMap[ref] = unit
|
||||
@@ -1565,12 +1565,12 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
for _, seriesHash := range seriesHash {
|
||||
series, exists := seriesStore[seriesHash]
|
||||
if !exists {
|
||||
logger.Warningf("alert_eval_%d datasource_%d series:%+v not found", rule.Id, dsId, series)
|
||||
logger.Warningf("rule_eval rid:%d series:%+v not found", rule.Id, series)
|
||||
continue
|
||||
}
|
||||
t, v, exists := series.Last()
|
||||
if !exists {
|
||||
logger.Warningf("alert_eval_%d datasource_%d series:%+v value not found", rule.Id, dsId, series)
|
||||
logger.Warningf("rule_eval rid:%d series:%+v value not found", rule.Id, series)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1601,12 +1601,12 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
ts = int64(t)
|
||||
sample = series
|
||||
value = v
|
||||
logger.Infof("alert_eval_%d datasource_%d origin series labels:%+v", rule.Id, dsId, series.Metric)
|
||||
logger.Infof("rule_eval rid:%d origin series labels:%+v", rule.Id, series.Metric)
|
||||
}
|
||||
|
||||
isTriggered := parser.CalcWithRid(trigger.Exp, m, rule.Id)
|
||||
// 此条日志很重要,是告警判断的现场值
|
||||
logger.Infof("alert_eval_%d datasource_%d trigger:%+v exp:%s res:%v m:%v", rule.Id, dsId, trigger, trigger.Exp, isTriggered, m)
|
||||
logger.Infof("rule_eval rid:%d trigger:%+v exp:%s res:%v m:%v", rule.Id, trigger, trigger.Exp, isTriggered, m)
|
||||
|
||||
var values string
|
||||
for k, v := range m {
|
||||
@@ -1679,7 +1679,7 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
|
||||
// 检查是否超过 resolve_after 时间
|
||||
if now-int64(lastTs) > int64(ruleQuery.NodataTrigger.ResolveAfter) {
|
||||
logger.Infof("alert_eval_%d datasource_%d series:%+v resolve after %d seconds now:%d lastTs:%d", rule.Id, dsId, lastSeries, ruleQuery.NodataTrigger.ResolveAfter, now, int64(lastTs))
|
||||
logger.Infof("rule_eval rid:%d series:%+v resolve after %d seconds now:%d lastTs:%d", rule.Id, lastSeries, ruleQuery.NodataTrigger.ResolveAfter, now, int64(lastTs))
|
||||
delete(arw.LastSeriesStore, hash)
|
||||
continue
|
||||
}
|
||||
@@ -1700,7 +1700,7 @@ func (arw *AlertRuleWorker) GetAnomalyPoint(rule *models.AlertRule, dsId int64)
|
||||
TriggerType: models.TriggerTypeNodata,
|
||||
}
|
||||
points = append(points, point)
|
||||
logger.Infof("alert_eval_%d datasource_%d nodata point:%+v", rule.Id, dsId, point)
|
||||
logger.Infof("rule_eval rid:%d nodata point:%+v", rule.Id, point)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,28 +41,8 @@ func IsMuted(rule *models.AlertRule, event *models.AlertCurEvent, targetCache *m
|
||||
|
||||
// TimeSpanMuteStrategy 根据规则配置的告警生效时间段过滤,如果产生的告警不在规则配置的告警生效时间段内,则不告警,即被mute
|
||||
// 时间范围,左闭右开,默认范围:00:00-24:00
|
||||
// 如果规则配置了时区,则在该时区下进行时间判断;如果时区为空,则使用系统时区
|
||||
func TimeSpanMuteStrategy(rule *models.AlertRule, event *models.AlertCurEvent) bool {
|
||||
// 确定使用的时区
|
||||
var targetLoc *time.Location
|
||||
var err error
|
||||
|
||||
timezone := rule.TimeZone
|
||||
if timezone == "" {
|
||||
// 如果时区为空,使用系统时区(保持原有逻辑)
|
||||
targetLoc = time.Local
|
||||
} else {
|
||||
// 加载规则配置的时区
|
||||
targetLoc, err = time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
// 如果时区加载失败,记录错误并使用系统时区
|
||||
logger.Warningf("Failed to load timezone %s for rule %d, using system timezone: %v", timezone, rule.Id, err)
|
||||
targetLoc = time.Local
|
||||
}
|
||||
}
|
||||
|
||||
// 将触发时间转换到目标时区
|
||||
tm := time.Unix(event.TriggerTime, 0).In(targetLoc)
|
||||
tm := time.Unix(event.TriggerTime, 0)
|
||||
triggerTime := tm.Format("15:04")
|
||||
triggerWeek := strconv.Itoa(int(tm.Weekday()))
|
||||
|
||||
@@ -122,7 +102,7 @@ func IdentNotExistsMuteStrategy(rule *models.AlertRule, event *models.AlertCurEv
|
||||
// 如果是target_up的告警,且ident已经不存在了,直接过滤掉
|
||||
// 这里的判断有点太粗暴了,但是目前没有更好的办法
|
||||
if !exists && strings.Contains(rule.PromQl, "target_up") {
|
||||
logger.Debugf("alert_eval_%d [IdentNotExistsMuteStrategy] mute: cluster:%s ident:%s", rule.Id, event.Cluster, ident)
|
||||
logger.Debugf("[%s] mute: rule_eval:%d cluster:%s ident:%s", "IdentNotExistsMuteStrategy", rule.Id, event.Cluster, ident)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -144,7 +124,7 @@ func BgNotMatchMuteStrategy(rule *models.AlertRule, event *models.AlertCurEvent,
|
||||
// 对于包含ident的告警事件,check一下ident所属bg和rule所属bg是否相同
|
||||
// 如果告警规则选择了只在本BG生效,那其他BG的机器就不能因此规则产生告警
|
||||
if exists && !target.MatchGroupId(rule.GroupId) {
|
||||
logger.Debugf("alert_eval_%d [BgNotMatchMuteStrategy] mute: cluster:%s", rule.Id, event.Cluster)
|
||||
logger.Debugf("[%s] mute: rule_eval:%d cluster:%s", "BgNotMatchMuteStrategy", rule.Id, event.Cluster)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -131,7 +131,7 @@ func (p *Processor) Handle(anomalyPoints []models.AnomalyPoint, from string, inh
|
||||
p.inhibit = inhibit
|
||||
cachedRule := p.alertRuleCache.Get(p.rule.Id)
|
||||
if cachedRule == nil {
|
||||
logger.Warningf("alert_eval_%d datasource_%d handle error: rule not found, maybe rule has been deleted, anomalyPoints:%+v", p.rule.Id, p.datasourceId, anomalyPoints)
|
||||
logger.Warningf("process handle error: rule not found %+v rule_id:%d maybe rule has been deleted", anomalyPoints, p.rule.Id)
|
||||
p.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", p.DatasourceId()), "handle_event", p.BusiGroupCache.GetNameByBusiGroupId(p.rule.GroupId), fmt.Sprintf("%v", p.rule.Id)).Inc()
|
||||
return
|
||||
}
|
||||
@@ -156,14 +156,14 @@ func (p *Processor) Handle(anomalyPoints []models.AnomalyPoint, from string, inh
|
||||
eventCopy := event.DeepCopy()
|
||||
event = dispatch.HandleEventPipeline(cachedRule.PipelineConfigs, eventCopy, event, dispatch.EventProcessorCache, p.ctx, cachedRule.Id, "alert_rule")
|
||||
if event == nil {
|
||||
logger.Infof("alert_eval_%d datasource_%d is muted drop by pipeline event:%s", p.rule.Id, p.datasourceId, eventCopy.Hash)
|
||||
logger.Infof("rule_eval:%s is muted drop by pipeline event:%s", p.Key(), eventCopy.Hash)
|
||||
continue
|
||||
}
|
||||
|
||||
// event mute
|
||||
isMuted, detail, muteId := mute.IsMuted(cachedRule, event, p.TargetCache, p.alertMuteCache)
|
||||
if isMuted {
|
||||
logger.Infof("alert_eval_%d datasource_%d is muted, detail:%s event:%s", p.rule.Id, p.datasourceId, detail, event.Hash)
|
||||
logger.Infof("rule_eval:%s is muted, detail:%s event:%s", p.Key(), detail, event.Hash)
|
||||
p.Stats.CounterMuteTotal.WithLabelValues(
|
||||
fmt.Sprintf("%v", event.GroupName),
|
||||
fmt.Sprintf("%v", p.rule.Id),
|
||||
@@ -174,7 +174,7 @@ func (p *Processor) Handle(anomalyPoints []models.AnomalyPoint, from string, inh
|
||||
}
|
||||
|
||||
if dispatch.EventMuteHook(event) {
|
||||
logger.Infof("alert_eval_%d datasource_%d is muted by hook event:%s", p.rule.Id, p.datasourceId, event.Hash)
|
||||
logger.Infof("rule_eval:%s is muted by hook event:%s", p.Key(), event.Hash)
|
||||
p.Stats.CounterMuteTotal.WithLabelValues(
|
||||
fmt.Sprintf("%v", event.GroupName),
|
||||
fmt.Sprintf("%v", p.rule.Id),
|
||||
@@ -247,7 +247,7 @@ func (p *Processor) BuildEvent(anomalyPoint models.AnomalyPoint, from string, no
|
||||
|
||||
if err := json.Unmarshal([]byte(p.rule.Annotations), &event.AnnotationsJSON); err != nil {
|
||||
event.AnnotationsJSON = make(map[string]string) // 解析失败时使用空 map
|
||||
logger.Warningf("alert_eval_%d datasource_%d unmarshal annotations json failed: %v", p.rule.Id, p.datasourceId, err)
|
||||
logger.Warningf("unmarshal annotations json failed: %v, rule: %d", err, p.rule.Id)
|
||||
}
|
||||
|
||||
if event.TriggerValues != "" && strings.Count(event.TriggerValues, "$") > 1 {
|
||||
@@ -272,7 +272,7 @@ func (p *Processor) BuildEvent(anomalyPoint models.AnomalyPoint, from string, no
|
||||
pt.GroupNames = p.BusiGroupCache.GetNamesByBusiGroupIds(pt.GroupIds)
|
||||
event.Target = pt
|
||||
} else {
|
||||
logger.Infof("alert_eval_%d datasource_%d fill event target error, ident: %s doesn't exist in cache.", p.rule.Id, p.datasourceId, event.TargetIdent)
|
||||
logger.Infof("fill event target error, ident: %s doesn't exist in cache.", event.TargetIdent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,19 +371,19 @@ func (p *Processor) RecoverSingle(byRecover bool, hash string, now int64, value
|
||||
lastPendingEvent, has := p.pendingsUseByRecover.Get(hash)
|
||||
if !has {
|
||||
// 说明没有产生过异常点,就不需要恢复了
|
||||
logger.Debugf("alert_eval_%d datasource_%d event:%s do not has pending event, not recover", p.rule.Id, p.datasourceId, event.Hash)
|
||||
logger.Debugf("rule_eval:%s event:%s do not has pending event, not recover", p.Key(), event.Hash)
|
||||
return
|
||||
}
|
||||
|
||||
if now-lastPendingEvent.LastEvalTime < cachedRule.RecoverDuration {
|
||||
logger.Debugf("alert_eval_%d datasource_%d event:%s not recover", p.rule.Id, p.datasourceId, event.Hash)
|
||||
logger.Debugf("rule_eval:%s event:%s not recover", p.Key(), event.Hash)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果设置了恢复条件,则不能在此处恢复,必须依靠 recoverPoint 来恢复
|
||||
if event.RecoverConfig.JudgeType != models.Origin && !byRecover {
|
||||
logger.Debugf("alert_eval_%d datasource_%d event:%s not recover", p.rule.Id, p.datasourceId, event.Hash)
|
||||
logger.Debugf("rule_eval:%s event:%s not recover", p.Key(), event.Hash)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ func (p *Processor) handleEvent(events []*models.AlertCurEvent) {
|
||||
func (p *Processor) inhibitEvent(events []*models.AlertCurEvent, highSeverity int) {
|
||||
for _, event := range events {
|
||||
if p.inhibit && event.Severity > highSeverity {
|
||||
logger.Debugf("alert_eval_%d datasource_%d event:%s inhibit highSeverity:%d", p.rule.Id, p.datasourceId, event.Hash, highSeverity)
|
||||
logger.Debugf("rule_eval:%s event:%s inhibit highSeverity:%d", p.Key(), event.Hash, highSeverity)
|
||||
continue
|
||||
}
|
||||
p.fireEvent(event)
|
||||
@@ -476,7 +476,7 @@ func (p *Processor) fireEvent(event *models.AlertCurEvent) {
|
||||
|
||||
message := "unknown"
|
||||
defer func() {
|
||||
logger.Infof("alert_eval_%d datasource_%d event-hash-%s %s", p.rule.Id, p.datasourceId, event.Hash, message)
|
||||
logger.Infof("rule_eval:%s event-hash-%s %s", p.Key(), event.Hash, message)
|
||||
}()
|
||||
|
||||
if fired, has := p.fires.Get(event.Hash); has {
|
||||
@@ -527,7 +527,7 @@ func (p *Processor) pushEventToQueue(e *models.AlertCurEvent) {
|
||||
|
||||
dispatch.LogEvent(e, "push_queue")
|
||||
if !queue.EventQueue.PushFront(e) {
|
||||
logger.Warningf("alert_eval_%d datasource_%d event_push_queue: queue is full, event:%s", p.rule.Id, p.datasourceId, e.Hash)
|
||||
logger.Warningf("event_push_queue: queue is full, event:%s", e.Hash)
|
||||
p.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", p.DatasourceId()), "push_event_queue", p.BusiGroupCache.GetNameByBusiGroupId(p.rule.GroupId), fmt.Sprintf("%v", p.rule.Id)).Inc()
|
||||
}
|
||||
}
|
||||
@@ -538,7 +538,7 @@ func (p *Processor) RecoverAlertCurEventFromDb() {
|
||||
|
||||
curEvents, err := models.AlertCurEventGetByRuleIdAndDsId(p.ctx, p.rule.Id, p.datasourceId)
|
||||
if err != nil {
|
||||
logger.Errorf("alert_eval_%d datasource_%d recover event from db failed, err:%s", p.rule.Id, p.datasourceId, err)
|
||||
logger.Errorf("recover event from db for rule:%s failed, err:%s", p.Key(), err)
|
||||
p.Stats.CounterRuleEvalErrorTotal.WithLabelValues(fmt.Sprintf("%v", p.DatasourceId()), "get_recover_event", p.BusiGroupCache.GetNameByBusiGroupId(p.rule.GroupId), fmt.Sprintf("%v", p.rule.Id)).Inc()
|
||||
p.fires = NewAlertCurEventMap(nil)
|
||||
return
|
||||
|
||||
@@ -53,8 +53,6 @@ func (rt *Router) Config(r *gin.Engine) {
|
||||
service.POST("/event-persist", rt.eventPersist)
|
||||
service.POST("/make-event", rt.makeEvent)
|
||||
service.GET("/event-detail/:hash", rt.eventDetail)
|
||||
service.GET("/alert-eval-detail/:id", rt.alertEvalDetail)
|
||||
service.GET("/trace-logs/:traceid", rt.traceLogs)
|
||||
}
|
||||
|
||||
func Render(c *gin.Context, data, msg interface{}) {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/pkg/loggrep"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (rt *Router) alertEvalDetail(c *gin.Context) {
|
||||
id := ginx.UrlParamStr(c, "id")
|
||||
if !loggrep.IsValidRuleID(id) {
|
||||
ginx.Bomb(200, "invalid rule id format")
|
||||
}
|
||||
|
||||
instance := fmt.Sprintf("%s:%d", rt.Alert.Heartbeat.IP, rt.HTTP.Port)
|
||||
|
||||
keyword := fmt.Sprintf("alert_eval_%s", id)
|
||||
logs, err := loggrep.GrepLogDir(rt.LogDir, keyword)
|
||||
ginx.Dangerous(err)
|
||||
|
||||
ginx.NewRender(c).Data(loggrep.EventDetailResp{
|
||||
Logs: logs,
|
||||
Instance: instance,
|
||||
}, nil)
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/alert/queue"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/poster"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/pkg/loggrep"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) eventDetail(c *gin.Context) {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/loggrep"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (rt *Router) traceLogs(c *gin.Context) {
|
||||
traceId := ginx.UrlParamStr(c, "traceid")
|
||||
if !loggrep.IsValidTraceID(traceId) {
|
||||
ginx.Bomb(200, "invalid trace id format")
|
||||
}
|
||||
|
||||
instance := fmt.Sprintf("%s:%d", rt.Alert.Heartbeat.IP, rt.HTTP.Port)
|
||||
|
||||
keyword := "trace_id=" + traceId
|
||||
logs, err := loggrep.GrepLatestLogFiles(rt.LogDir, keyword)
|
||||
ginx.Dangerous(err)
|
||||
|
||||
ginx.NewRender(c).Data(loggrep.EventDetailResp{
|
||||
Logs: logs,
|
||||
Instance: instance,
|
||||
}, nil)
|
||||
}
|
||||
@@ -21,12 +21,6 @@ type Center struct {
|
||||
CleanPipelineExecutionDay int
|
||||
MigrateBusiGroupLabel bool
|
||||
RSA httpx.RSAConfig
|
||||
AIAgent AIAgent
|
||||
}
|
||||
|
||||
type AIAgent struct {
|
||||
Enable bool `toml:"Enable"`
|
||||
SkillsPath string `toml:"SkillsPath"`
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
|
||||
@@ -300,14 +300,6 @@ ops:
|
||||
cname: View Alerting Engines
|
||||
- name: /system/version
|
||||
cname: View Product Version
|
||||
- name: /ai-config/agents
|
||||
cname: AI Config - Agents
|
||||
- name: /ai-config/llm-configs
|
||||
cname: AI Config - LLM Configs
|
||||
- name: /ai-config/skills
|
||||
cname: AI Config - Skills
|
||||
- name: /ai-config/mcp-servers
|
||||
cname: AI Config - MCP Servers
|
||||
|
||||
`
|
||||
)
|
||||
|
||||
@@ -24,11 +24,11 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/prom"
|
||||
"github.com/ccfos/nightingale/v6/pushgw/idents"
|
||||
"github.com/ccfos/nightingale/v6/storage"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rakyll/statik/fs"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
"github.com/toolkits/pkg/runner"
|
||||
)
|
||||
@@ -370,7 +370,6 @@ func (rt *Router) Config(r *gin.Engine) {
|
||||
// pages.GET("/alert-rules/builtin/alerts-cates", rt.auth(), rt.user(), rt.builtinAlertCateGets)
|
||||
// pages.GET("/alert-rules/builtin/list", rt.auth(), rt.user(), rt.builtinAlertRules)
|
||||
pages.GET("/alert-rules/callbacks", rt.auth(), rt.user(), rt.alertRuleCallbacks)
|
||||
pages.GET("/timezones", rt.auth(), rt.user(), rt.timezonesGet)
|
||||
|
||||
pages.GET("/busi-groups/alert-rules", rt.auth(), rt.user(), rt.perm("/alert-rules"), rt.alertRuleGetsByGids)
|
||||
pages.GET("/busi-group/:id/alert-rules", rt.auth(), rt.user(), rt.perm("/alert-rules"), rt.alertRuleGets)
|
||||
@@ -420,8 +419,6 @@ func (rt *Router) Config(r *gin.Engine) {
|
||||
pages.GET("/alert-his-event/:eid", rt.alertHisEventGet)
|
||||
pages.GET("/event-notify-records/:eid", rt.notificationRecordList)
|
||||
pages.GET("/event-detail/:hash", rt.eventDetailPage)
|
||||
pages.GET("/alert-eval-detail/:id", rt.alertEvalDetailPage)
|
||||
pages.GET("/trace-logs/:traceid", rt.traceLogsPage)
|
||||
|
||||
// card logic
|
||||
pages.GET("/alert-cur-events/list", rt.auth(), rt.user(), rt.alertCurEventsList)
|
||||
@@ -520,50 +517,6 @@ func (rt *Router) Config(r *gin.Engine) {
|
||||
pages.PUT("/config", rt.auth(), rt.admin(), rt.configPutByKey)
|
||||
pages.GET("/site-info", rt.siteInfo)
|
||||
|
||||
// AI Config management
|
||||
pages.GET("/ai-agents", rt.auth(), rt.admin(), rt.aiAgentGets)
|
||||
pages.GET("/ai-agent/:id", rt.auth(), rt.admin(), rt.aiAgentGet)
|
||||
pages.POST("/ai-agents", rt.auth(), rt.admin(), rt.aiAgentAdd)
|
||||
pages.PUT("/ai-agent/:id", rt.auth(), rt.admin(), rt.aiAgentPut)
|
||||
pages.DELETE("/ai-agent/:id", rt.auth(), rt.admin(), rt.aiAgentDel)
|
||||
|
||||
pages.GET("/ai-llm-configs", rt.auth(), rt.admin(), rt.aiLLMConfigGets)
|
||||
pages.GET("/ai-llm-config/:id", rt.auth(), rt.admin(), rt.aiLLMConfigGet)
|
||||
pages.POST("/ai-llm-configs", rt.auth(), rt.admin(), rt.aiLLMConfigAdd)
|
||||
pages.PUT("/ai-llm-config/:id", rt.auth(), rt.admin(), rt.aiLLMConfigPut)
|
||||
pages.DELETE("/ai-llm-config/:id", rt.auth(), rt.admin(), rt.aiLLMConfigDel)
|
||||
pages.POST("/ai-llm-config/test", rt.auth(), rt.admin(), rt.aiLLMConfigTest)
|
||||
|
||||
pages.GET("/ai-skills", rt.auth(), rt.admin(), rt.aiSkillGets)
|
||||
pages.GET("/ai-skill/:id", rt.auth(), rt.admin(), rt.aiSkillGet)
|
||||
pages.POST("/ai-skills", rt.auth(), rt.admin(), rt.aiSkillAdd)
|
||||
pages.PUT("/ai-skill/:id", rt.auth(), rt.admin(), rt.aiSkillPut)
|
||||
pages.DELETE("/ai-skill/:id", rt.auth(), rt.admin(), rt.aiSkillDel)
|
||||
pages.POST("/ai-skills/import", rt.auth(), rt.admin(), rt.aiSkillImport)
|
||||
pages.POST("/ai-skill/:id/files", rt.auth(), rt.admin(), rt.aiSkillFileAdd)
|
||||
pages.GET("/ai-skill-file/:fileId", rt.auth(), rt.admin(), rt.aiSkillFileGet)
|
||||
pages.DELETE("/ai-skill-file/:fileId", rt.auth(), rt.admin(), rt.aiSkillFileDel)
|
||||
|
||||
pages.GET("/mcp-servers", rt.auth(), rt.admin(), rt.mcpServerGets)
|
||||
pages.GET("/mcp-server/:id", rt.auth(), rt.admin(), rt.mcpServerGet)
|
||||
pages.POST("/mcp-servers", rt.auth(), rt.admin(), rt.mcpServerAdd)
|
||||
pages.PUT("/mcp-server/:id", rt.auth(), rt.admin(), rt.mcpServerPut)
|
||||
pages.DELETE("/mcp-server/:id", rt.auth(), rt.admin(), rt.mcpServerDel)
|
||||
pages.POST("/ai-agent/:id/test", rt.auth(), rt.admin(), rt.aiAgentTest)
|
||||
pages.POST("/mcp-server/test", rt.auth(), rt.admin(), rt.mcpServerTest)
|
||||
pages.GET("/mcp-server/:id/tools", rt.auth(), rt.admin(), rt.mcpServerTools)
|
||||
|
||||
// AI Conversations
|
||||
pages.GET("/ai-conversations", rt.auth(), rt.user(), rt.aiConversationGets)
|
||||
pages.POST("/ai-conversations", rt.auth(), rt.user(), rt.aiConversationAdd)
|
||||
pages.GET("/ai-conversation/:id", rt.auth(), rt.user(), rt.aiConversationGet)
|
||||
pages.PUT("/ai-conversation/:id", rt.auth(), rt.user(), rt.aiConversationPut)
|
||||
pages.DELETE("/ai-conversation/:id", rt.auth(), rt.user(), rt.aiConversationDel)
|
||||
pages.POST("/ai-conversation/:id/messages", rt.auth(), rt.user(), rt.aiConversationMessageAdd)
|
||||
|
||||
// AI chat (SSE), dispatches by action_key
|
||||
pages.POST("/ai-chat", rt.auth(), rt.user(), rt.aiChat)
|
||||
|
||||
// source token 相关路由
|
||||
pages.POST("/source-token", rt.auth(), rt.user(), rt.sourceTokenAdd)
|
||||
|
||||
@@ -714,6 +667,7 @@ func (rt *Router) Config(r *gin.Engine) {
|
||||
service.GET("/alert-cur-events-get-by-rid", rt.alertCurEventsGetByRid)
|
||||
service.GET("/alert-his-events", rt.alertHisEventsList)
|
||||
service.GET("/alert-his-event/:eid", rt.alertHisEventGet)
|
||||
service.GET("/event-detail/:hash", rt.eventDetailJSON)
|
||||
|
||||
service.GET("/task-tpl/:tid", rt.taskTplGetByService)
|
||||
service.GET("/task-tpls", rt.taskTplGetsByService)
|
||||
|
||||
@@ -1,747 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ========================
|
||||
// AI Agent handlers
|
||||
// ========================
|
||||
|
||||
func (rt *Router) aiAgentGets(c *gin.Context) {
|
||||
lst, err := models.AIAgentGets(rt.Ctx)
|
||||
ginx.Dangerous(err)
|
||||
ginx.NewRender(c).Data(lst, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiAgentGet(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AIAgentGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "ai agent not found")
|
||||
}
|
||||
ginx.NewRender(c).Data(obj, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiAgentAdd(c *gin.Context) {
|
||||
var obj models.AIAgent
|
||||
ginx.BindJSON(c, &obj)
|
||||
ginx.Dangerous(obj.Verify())
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
|
||||
ginx.Dangerous(obj.Create(rt.Ctx, me.Username))
|
||||
ginx.NewRender(c).Data(obj.Id, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiAgentPut(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AIAgentGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "ai agent not found")
|
||||
}
|
||||
|
||||
var ref models.AIAgent
|
||||
ginx.BindJSON(c, &ref)
|
||||
ginx.Dangerous(ref.Verify())
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
|
||||
ginx.NewRender(c).Message(obj.Update(rt.Ctx, me.Username, ref))
|
||||
}
|
||||
|
||||
func (rt *Router) aiAgentDel(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AIAgentGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "ai agent not found")
|
||||
}
|
||||
ginx.NewRender(c).Message(obj.Delete(rt.Ctx))
|
||||
}
|
||||
|
||||
// ========================
|
||||
// AI Skill handlers
|
||||
// ========================
|
||||
|
||||
func (rt *Router) aiSkillGets(c *gin.Context) {
|
||||
search := ginx.QueryStr(c, "search", "")
|
||||
lst, err := models.AISkillGets(rt.Ctx, search)
|
||||
ginx.Dangerous(err)
|
||||
ginx.NewRender(c).Data(lst, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiSkillGet(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AISkillGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "ai skill not found")
|
||||
}
|
||||
|
||||
// Include associated files (without content)
|
||||
files, err := models.AISkillFileGets(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
obj.Files = files
|
||||
|
||||
ginx.NewRender(c).Data(obj, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiSkillAdd(c *gin.Context) {
|
||||
var obj models.AISkill
|
||||
ginx.BindJSON(c, &obj)
|
||||
ginx.Dangerous(obj.Verify())
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
obj.CreatedBy = me.Username
|
||||
obj.UpdatedBy = me.Username
|
||||
|
||||
ginx.Dangerous(obj.Create(rt.Ctx))
|
||||
ginx.NewRender(c).Data(obj.Id, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiSkillPut(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AISkillGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "ai skill not found")
|
||||
}
|
||||
|
||||
var ref models.AISkill
|
||||
ginx.BindJSON(c, &ref)
|
||||
ginx.Dangerous(ref.Verify())
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
ref.UpdatedBy = me.Username
|
||||
|
||||
ginx.NewRender(c).Message(obj.Update(rt.Ctx, ref))
|
||||
}
|
||||
|
||||
func (rt *Router) aiSkillDel(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AISkillGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "ai skill not found")
|
||||
}
|
||||
|
||||
// Cascade delete skill files
|
||||
ginx.Dangerous(models.AISkillFileDeleteBySkillId(rt.Ctx, id))
|
||||
ginx.NewRender(c).Message(obj.Delete(rt.Ctx))
|
||||
}
|
||||
|
||||
func (rt *Router) aiSkillImport(c *gin.Context) {
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
ginx.Dangerous(err)
|
||||
defer file.Close()
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext != ".md" {
|
||||
ginx.Bomb(http.StatusBadRequest, "only .md files are supported")
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(file)
|
||||
ginx.Dangerous(err)
|
||||
|
||||
meta, instructions := parseSkillMarkdown(string(content), header.Filename, ext)
|
||||
me := c.MustGet("user").(*models.User)
|
||||
|
||||
skill := models.AISkill{
|
||||
Name: meta.Name,
|
||||
Description: meta.Description,
|
||||
Instructions: instructions,
|
||||
License: meta.License,
|
||||
Compatibility: meta.Compatibility,
|
||||
Metadata: meta.Metadata,
|
||||
AllowedTools: meta.AllowedTools,
|
||||
CreatedBy: me.Username,
|
||||
UpdatedBy: me.Username,
|
||||
}
|
||||
ginx.Dangerous(skill.Create(rt.Ctx))
|
||||
ginx.NewRender(c).Data(skill.Id, nil)
|
||||
}
|
||||
|
||||
// parseSkillMarkdown parses a SKILL.md file with optional YAML frontmatter.
|
||||
// Frontmatter format:
|
||||
//
|
||||
// ---
|
||||
// name: my-skill
|
||||
// description: what this skill does
|
||||
// ---
|
||||
// # Actual instructions content...
|
||||
type skillFrontmatter struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
License string `yaml:"license"`
|
||||
Compatibility string `yaml:"compatibility"`
|
||||
Metadata map[string]string `yaml:"metadata"`
|
||||
AllowedTools string `yaml:"allowed-tools"`
|
||||
}
|
||||
|
||||
func parseSkillMarkdown(content, filename, ext string) (meta skillFrontmatter, instructions string) {
|
||||
text := strings.TrimSpace(content)
|
||||
|
||||
// Try to parse YAML frontmatter (between --- delimiters)
|
||||
if strings.HasPrefix(text, "---") {
|
||||
endIdx := strings.Index(text[3:], "\n---")
|
||||
if endIdx >= 0 {
|
||||
frontmatter := text[3 : 3+endIdx]
|
||||
body := strings.TrimSpace(text[3+endIdx+4:]) // skip past closing ---
|
||||
|
||||
if yaml.Unmarshal([]byte(frontmatter), &meta) == nil && meta.Name != "" {
|
||||
return meta, body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No valid frontmatter, fallback: filename as name, entire content as instructions
|
||||
meta.Name = strings.TrimSuffix(filename, ext)
|
||||
return meta, content
|
||||
}
|
||||
|
||||
// ========================
|
||||
// AI Skill File handlers
|
||||
// ========================
|
||||
|
||||
func (rt *Router) aiSkillFileAdd(c *gin.Context) {
|
||||
skillId := ginx.UrlParamInt64(c, "id")
|
||||
|
||||
// Verify skill exists
|
||||
skill, err := models.AISkillGetById(rt.Ctx, skillId)
|
||||
ginx.Dangerous(err)
|
||||
if skill == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "ai skill not found")
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
ginx.Dangerous(err)
|
||||
defer file.Close()
|
||||
|
||||
// Validate file extension
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
allowed := map[string]bool{".md": true, ".txt": true, ".json": true, ".yaml": true, ".yml": true, ".csv": true}
|
||||
if !allowed[ext] {
|
||||
ginx.Bomb(http.StatusBadRequest, "file type not allowed, only .md/.txt/.json/.yaml/.csv")
|
||||
}
|
||||
|
||||
// Validate file size (2MB max)
|
||||
if header.Size > 2*1024*1024 {
|
||||
ginx.Bomb(http.StatusBadRequest, "file size exceeds 2MB limit")
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(file)
|
||||
ginx.Dangerous(err)
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
skillFile := models.AISkillFile{
|
||||
SkillId: skillId,
|
||||
Name: header.Filename,
|
||||
Content: string(content),
|
||||
CreatedBy: me.Username,
|
||||
}
|
||||
ginx.Dangerous(skillFile.Create(rt.Ctx))
|
||||
ginx.NewRender(c).Data(skillFile.Id, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiSkillFileGet(c *gin.Context) {
|
||||
fileId := ginx.UrlParamInt64(c, "fileId")
|
||||
obj, err := models.AISkillFileGetById(rt.Ctx, fileId)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "file not found")
|
||||
}
|
||||
ginx.NewRender(c).Data(obj, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiSkillFileDel(c *gin.Context) {
|
||||
fileId := ginx.UrlParamInt64(c, "fileId")
|
||||
obj, err := models.AISkillFileGetById(rt.Ctx, fileId)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "file not found")
|
||||
}
|
||||
ginx.NewRender(c).Message(obj.Delete(rt.Ctx))
|
||||
}
|
||||
|
||||
// ========================
|
||||
// MCP Server handlers
|
||||
// ========================
|
||||
|
||||
func (rt *Router) mcpServerGets(c *gin.Context) {
|
||||
lst, err := models.MCPServerGets(rt.Ctx)
|
||||
ginx.Dangerous(err)
|
||||
ginx.NewRender(c).Data(lst, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) mcpServerGet(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.MCPServerGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "mcp server not found")
|
||||
}
|
||||
ginx.NewRender(c).Data(obj, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) mcpServerAdd(c *gin.Context) {
|
||||
var obj models.MCPServer
|
||||
ginx.BindJSON(c, &obj)
|
||||
ginx.Dangerous(obj.Verify())
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
obj.CreatedBy = me.Username
|
||||
obj.UpdatedBy = me.Username
|
||||
|
||||
ginx.Dangerous(obj.Create(rt.Ctx))
|
||||
ginx.NewRender(c).Data(obj.Id, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) mcpServerPut(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.MCPServerGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "mcp server not found")
|
||||
}
|
||||
|
||||
var ref models.MCPServer
|
||||
ginx.BindJSON(c, &ref)
|
||||
ginx.Dangerous(ref.Verify())
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
ref.UpdatedBy = me.Username
|
||||
|
||||
ginx.NewRender(c).Message(obj.Update(rt.Ctx, ref))
|
||||
}
|
||||
|
||||
func (rt *Router) mcpServerDel(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.MCPServerGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "mcp server not found")
|
||||
}
|
||||
ginx.NewRender(c).Message(obj.Delete(rt.Ctx))
|
||||
}
|
||||
|
||||
// ========================
|
||||
// AI LLM Config handlers
|
||||
// ========================
|
||||
|
||||
func (rt *Router) aiLLMConfigGets(c *gin.Context) {
|
||||
lst, err := models.AILLMConfigGets(rt.Ctx)
|
||||
ginx.Dangerous(err)
|
||||
ginx.NewRender(c).Data(lst, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiLLMConfigGet(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AILLMConfigGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "ai llm config not found")
|
||||
}
|
||||
ginx.NewRender(c).Data(obj, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiLLMConfigAdd(c *gin.Context) {
|
||||
var obj models.AILLMConfig
|
||||
ginx.BindJSON(c, &obj)
|
||||
ginx.Dangerous(obj.Verify())
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
|
||||
ginx.Dangerous(obj.Create(rt.Ctx, me.Username))
|
||||
ginx.NewRender(c).Data(obj.Id, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiLLMConfigPut(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AILLMConfigGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "ai llm config not found")
|
||||
}
|
||||
|
||||
var ref models.AILLMConfig
|
||||
ginx.BindJSON(c, &ref)
|
||||
ginx.Dangerous(ref.Verify())
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
|
||||
ginx.NewRender(c).Message(obj.Update(rt.Ctx, me.Username, ref))
|
||||
}
|
||||
|
||||
func (rt *Router) aiLLMConfigDel(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AILLMConfigGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "ai llm config not found")
|
||||
}
|
||||
ginx.NewRender(c).Message(obj.Delete(rt.Ctx))
|
||||
}
|
||||
|
||||
func (rt *Router) aiLLMConfigTest(c *gin.Context) {
|
||||
var body struct {
|
||||
APIType string `json:"api_type"`
|
||||
APIURL string `json:"api_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Model string `json:"model"`
|
||||
ExtraConfig models.LLMExtraConfig `json:"extra_config"`
|
||||
}
|
||||
ginx.BindJSON(c, &body)
|
||||
|
||||
if body.APIType == "" || body.APIURL == "" || body.APIKey == "" || body.Model == "" {
|
||||
ginx.Bomb(http.StatusBadRequest, "api_type, api_url, api_key, model are required")
|
||||
}
|
||||
|
||||
obj := &models.AILLMConfig{
|
||||
APIType: body.APIType,
|
||||
APIURL: body.APIURL,
|
||||
APIKey: body.APIKey,
|
||||
Model: body.Model,
|
||||
ExtraConfig: body.ExtraConfig,
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
testErr := testAIAgent(obj)
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
|
||||
result := gin.H{
|
||||
"success": testErr == nil,
|
||||
"duration_ms": durationMs,
|
||||
}
|
||||
ginx.NewRender(c).Data(result, testErr)
|
||||
}
|
||||
|
||||
// ========================
|
||||
// AI Agent test
|
||||
// ========================
|
||||
|
||||
func (rt *Router) aiAgentTest(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
|
||||
agent, err := models.AIAgentGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if agent == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "ai agent not found")
|
||||
}
|
||||
|
||||
llmCfg, err := models.AILLMConfigGetById(rt.Ctx, agent.LLMConfigId)
|
||||
ginx.Dangerous(err)
|
||||
if llmCfg == nil {
|
||||
ginx.Bomb(http.StatusBadRequest, "referenced LLM config not found")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
testErr := testAIAgent(llmCfg)
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
|
||||
result := gin.H{
|
||||
"success": testErr == nil,
|
||||
"duration_ms": durationMs,
|
||||
}
|
||||
if testErr != nil {
|
||||
result["error"] = testErr.Error()
|
||||
}
|
||||
ginx.NewRender(c).Data(result, nil)
|
||||
}
|
||||
|
||||
func testAIAgent(p *models.AILLMConfig) error {
|
||||
extra := p.ExtraConfig
|
||||
|
||||
// Build HTTP client with ExtraConfig settings
|
||||
timeout := 30 * time.Second
|
||||
if extra.TimeoutSeconds > 0 {
|
||||
timeout = time.Duration(extra.TimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
if extra.SkipTLSVerify {
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
if extra.Proxy != "" {
|
||||
if proxyURL, err := url.Parse(extra.Proxy); err == nil {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: timeout, Transport: transport}
|
||||
|
||||
var reqURL string
|
||||
var reqBody []byte
|
||||
hdrs := map[string]string{"Content-Type": "application/json"}
|
||||
|
||||
switch p.APIType {
|
||||
case "openai":
|
||||
base := strings.TrimRight(p.APIURL, "/")
|
||||
if strings.HasSuffix(base, "/chat/completions") {
|
||||
reqURL = base
|
||||
} else {
|
||||
reqURL = base + "/chat/completions"
|
||||
}
|
||||
reqBody, _ = json.Marshal(map[string]interface{}{
|
||||
"model": p.Model,
|
||||
"messages": []map[string]string{{"role": "user", "content": "Hi"}},
|
||||
"max_tokens": 5,
|
||||
})
|
||||
hdrs["Authorization"] = "Bearer " + p.APIKey
|
||||
case "claude":
|
||||
reqURL = strings.TrimRight(p.APIURL, "/") + "/v1/messages"
|
||||
reqBody, _ = json.Marshal(map[string]interface{}{
|
||||
"model": p.Model,
|
||||
"messages": []map[string]string{{"role": "user", "content": "Hi"}},
|
||||
"max_tokens": 5,
|
||||
})
|
||||
hdrs["x-api-key"] = p.APIKey
|
||||
hdrs["anthropic-version"] = "2023-06-01"
|
||||
case "gemini":
|
||||
reqURL = strings.TrimRight(p.APIURL, "/") + "/v1beta/models/" + p.Model + ":generateContent?key=" + p.APIKey
|
||||
reqBody, _ = json.Marshal(map[string]interface{}{
|
||||
"contents": []map[string]interface{}{
|
||||
{"parts": []map[string]string{{"text": "Hi"}}},
|
||||
},
|
||||
})
|
||||
default:
|
||||
return fmt.Errorf("unsupported api_type: %s", p.APIType)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", reqURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range hdrs {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Apply custom headers from ExtraConfig
|
||||
for k, v := range extra.CustomHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if len(body) > 500 {
|
||||
body = body[:500]
|
||||
}
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========================
|
||||
// MCP Server test & tools
|
||||
// ========================
|
||||
|
||||
func (rt *Router) mcpServerTest(c *gin.Context) {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
ginx.BindJSON(c, &body)
|
||||
|
||||
if body.URL == "" {
|
||||
ginx.Bomb(http.StatusBadRequest, "url is required")
|
||||
}
|
||||
|
||||
obj := &models.MCPServer{
|
||||
URL: body.URL,
|
||||
Headers: body.Headers,
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
tools, testErr := listMCPTools(obj)
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
|
||||
result := gin.H{
|
||||
"success": testErr == nil,
|
||||
"duration_ms": durationMs,
|
||||
"tool_count": len(tools),
|
||||
}
|
||||
if testErr != nil {
|
||||
result["error"] = testErr.Error()
|
||||
}
|
||||
ginx.NewRender(c).Data(result, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) mcpServerTools(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.MCPServerGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "mcp server not found")
|
||||
}
|
||||
|
||||
tools, err := listMCPTools(obj)
|
||||
ginx.Dangerous(err)
|
||||
ginx.NewRender(c).Data(tools, nil)
|
||||
}
|
||||
|
||||
type mcpTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func listMCPTools(s *models.MCPServer) ([]mcpTool, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
hdrs := s.Headers
|
||||
|
||||
// Step 1: Initialize
|
||||
initResp, initSessionID, err := sendMCPRPC(client, s.URL, hdrs, "", 1, "initialize", map[string]interface{}{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]interface{}{},
|
||||
"clientInfo": map[string]interface{}{"name": "nightingale", "version": "1.0.0"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize: %v", err)
|
||||
}
|
||||
_ = initResp
|
||||
|
||||
// Send initialized notification
|
||||
sendMCPRPC(client, s.URL, hdrs, initSessionID, 0, "notifications/initialized", map[string]interface{}{})
|
||||
|
||||
// Step 2: List tools
|
||||
toolsResp, _, err := sendMCPRPC(client, s.URL, hdrs, initSessionID, 2, "tools/list", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tools/list: %v", err)
|
||||
}
|
||||
|
||||
if toolsResp == nil || toolsResp.Result == nil {
|
||||
return []mcpTool{}, nil
|
||||
}
|
||||
|
||||
toolsRaw, ok := toolsResp.Result["tools"]
|
||||
if !ok {
|
||||
return []mcpTool{}, nil
|
||||
}
|
||||
|
||||
toolsJSON, _ := json.Marshal(toolsRaw)
|
||||
var tools []mcpTool
|
||||
json.Unmarshal(toolsJSON, &tools)
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
type jsonRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID interface{} `json:"id"`
|
||||
Result map[string]interface{} `json:"result"`
|
||||
Error *jsonRPCError `json:"error"`
|
||||
}
|
||||
|
||||
type jsonRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func sendMCPRPC(client *http.Client, serverURL string, hdrs map[string]string, sessionID string, id int, method string, params interface{}) (*jsonRPCResponse, string, error) {
|
||||
body := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
if id > 0 {
|
||||
body["id"] = id
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", serverURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
if sessionID != "" {
|
||||
req.Header.Set("Mcp-Session-Id", sessionID)
|
||||
}
|
||||
for k, v := range hdrs {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
newSessionID := resp.Header.Get("Mcp-Session-Id")
|
||||
if newSessionID == "" {
|
||||
newSessionID = sessionID
|
||||
}
|
||||
|
||||
// Notification (no id) - no response body expected
|
||||
if id <= 0 {
|
||||
return nil, newSessionID, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if len(respBody) > 500 {
|
||||
respBody = respBody[:500]
|
||||
}
|
||||
return nil, newSessionID, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, newSessionID, err
|
||||
}
|
||||
|
||||
// Handle SSE response
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "text/event-stream") {
|
||||
for _, line := range strings.Split(string(respBody), "\n") {
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
var rpcResp jsonRPCResponse
|
||||
if json.Unmarshal([]byte(data), &rpcResp) == nil && (rpcResp.Result != nil || rpcResp.Error != nil) {
|
||||
if rpcResp.Error != nil {
|
||||
return &rpcResp, newSessionID, fmt.Errorf("RPC error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
|
||||
}
|
||||
return &rpcResp, newSessionID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, newSessionID, fmt.Errorf("no valid JSON-RPC response in SSE stream")
|
||||
}
|
||||
|
||||
// Handle JSON response
|
||||
var rpcResp jsonRPCResponse
|
||||
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
|
||||
if len(respBody) > 200 {
|
||||
respBody = respBody[:200]
|
||||
}
|
||||
return nil, newSessionID, fmt.Errorf("invalid response: %s", string(respBody))
|
||||
}
|
||||
|
||||
if rpcResp.Error != nil {
|
||||
return &rpcResp, newSessionID, fmt.Errorf("RPC error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
|
||||
}
|
||||
|
||||
return &rpcResp, newSessionID, nil
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (rt *Router) aiConversationGets(c *gin.Context) {
|
||||
me := c.MustGet("user").(*models.User)
|
||||
lst, err := models.AIConversationGetsByUserId(rt.Ctx, me.Id)
|
||||
ginx.Dangerous(err)
|
||||
ginx.NewRender(c).Data(lst, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiConversationAdd(c *gin.Context) {
|
||||
var obj models.AIConversation
|
||||
ginx.BindJSON(c, &obj)
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
obj.UserId = me.Id
|
||||
ginx.Dangerous(obj.Verify())
|
||||
ginx.Dangerous(obj.Create(rt.Ctx))
|
||||
ginx.NewRender(c).Data(obj, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiConversationGet(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AIConversationGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "conversation not found")
|
||||
}
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
if obj.UserId != me.Id {
|
||||
ginx.Bomb(http.StatusForbidden, "forbidden")
|
||||
}
|
||||
|
||||
messages, err := models.AIConversationMessageGetsByConversationId(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
|
||||
ginx.NewRender(c).Data(gin.H{
|
||||
"conversation": obj,
|
||||
"messages": messages,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) aiConversationPut(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AIConversationGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "conversation not found")
|
||||
}
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
if obj.UserId != me.Id {
|
||||
ginx.Bomb(http.StatusForbidden, "forbidden")
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
ginx.BindJSON(c, &body)
|
||||
|
||||
ginx.NewRender(c).Message(obj.Update(rt.Ctx, body.Title))
|
||||
}
|
||||
|
||||
func (rt *Router) aiConversationDel(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AIConversationGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "conversation not found")
|
||||
}
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
if obj.UserId != me.Id {
|
||||
ginx.Bomb(http.StatusForbidden, "forbidden")
|
||||
}
|
||||
|
||||
ginx.NewRender(c).Message(obj.Delete(rt.Ctx))
|
||||
}
|
||||
|
||||
func (rt *Router) aiConversationMessageAdd(c *gin.Context) {
|
||||
id := ginx.UrlParamInt64(c, "id")
|
||||
obj, err := models.AIConversationGetById(rt.Ctx, id)
|
||||
ginx.Dangerous(err)
|
||||
if obj == nil {
|
||||
ginx.Bomb(http.StatusNotFound, "conversation not found")
|
||||
}
|
||||
|
||||
me := c.MustGet("user").(*models.User)
|
||||
if obj.UserId != me.Id {
|
||||
ginx.Bomb(http.StatusForbidden, "forbidden")
|
||||
}
|
||||
|
||||
var msgs []models.AIConversationMessage
|
||||
ginx.BindJSON(c, &msgs)
|
||||
|
||||
for i := range msgs {
|
||||
msgs[i].ConversationId = id
|
||||
ginx.Dangerous(msgs[i].Create(rt.Ctx))
|
||||
}
|
||||
|
||||
// Update conversation timestamp
|
||||
obj.UpdateTime(rt.Ctx)
|
||||
|
||||
ginx.NewRender(c).Message(nil)
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/aiagent"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/prom"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
// AIChatRequest is the generic chat request dispatched by action_key.
|
||||
type AIChatRequest struct {
|
||||
ActionKey string `json:"action_key"` // e.g. "query_generator"
|
||||
UserInput string `json:"user_input"`
|
||||
History []aiagent.ChatMessage `json:"history,omitempty"`
|
||||
Context map[string]interface{} `json:"context,omitempty"` // action-specific params
|
||||
}
|
||||
|
||||
// actionHandler defines how each action_key is processed.
|
||||
type actionHandler struct {
|
||||
useCase string // maps to AIAgent.UseCase for finding the right agent config
|
||||
validate func(req *AIChatRequest) error
|
||||
selectTools func(req *AIChatRequest) []string
|
||||
buildPrompt func(req *AIChatRequest) string
|
||||
buildInputs func(req *AIChatRequest) map[string]string
|
||||
}
|
||||
|
||||
var actionRegistry = map[string]*actionHandler{
|
||||
"query_generator": {
|
||||
useCase: "chat",
|
||||
validate: validateQueryGenerator,
|
||||
selectTools: selectQueryGeneratorTools,
|
||||
buildPrompt: buildQueryGeneratorPrompt,
|
||||
buildInputs: buildQueryGeneratorInputs,
|
||||
},
|
||||
}
|
||||
|
||||
// --- query_generator action ---
|
||||
|
||||
func ctxStr(ctx map[string]interface{}, key string) string {
|
||||
if v, ok := ctx[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ctxInt64(ctx map[string]interface{}, key string) int64 {
|
||||
if v, ok := ctx[key]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func validateQueryGenerator(req *AIChatRequest) error {
|
||||
dsType := ctxStr(req.Context, "datasource_type")
|
||||
dsID := ctxInt64(req.Context, "datasource_id")
|
||||
if dsType == "" {
|
||||
return fmt.Errorf("context.datasource_type is required")
|
||||
}
|
||||
if dsID == 0 {
|
||||
return fmt.Errorf("context.datasource_id is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectQueryGeneratorTools(req *AIChatRequest) []string {
|
||||
dsType := ctxStr(req.Context, "datasource_type")
|
||||
switch dsType {
|
||||
case "prometheus":
|
||||
return []string{"list_metrics", "get_metric_labels"}
|
||||
case "mysql", "doris", "ck", "clickhouse", "pgsql", "postgresql":
|
||||
return []string{"list_databases", "list_tables", "describe_table"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildQueryGeneratorPrompt(req *AIChatRequest) string {
|
||||
dsType := ctxStr(req.Context, "datasource_type")
|
||||
dbName := ctxStr(req.Context, "database_name")
|
||||
tableName := ctxStr(req.Context, "table_name")
|
||||
|
||||
switch dsType {
|
||||
case "prometheus":
|
||||
return fmt.Sprintf(`You are a PromQL expert. The user wants to query Prometheus metrics.
|
||||
|
||||
User request: %s
|
||||
|
||||
Please use the available tools to explore the metrics and generate the correct PromQL query.
|
||||
- First use list_metrics to find relevant metrics
|
||||
- Then use get_metric_labels to understand the label structure
|
||||
- Finally provide the PromQL query as your Final Answer
|
||||
|
||||
Your Final Answer MUST be a valid JSON object with these fields:
|
||||
{"query": "<the PromQL query>", "explanation": "<brief explanation in the user's language>"}`, req.UserInput)
|
||||
|
||||
default: // SQL-based datasources
|
||||
dbContext := ""
|
||||
if dbName != "" {
|
||||
dbContext += fmt.Sprintf("\nTarget database: %s", dbName)
|
||||
}
|
||||
if tableName != "" {
|
||||
dbContext += fmt.Sprintf("\nTarget table: %s", tableName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`You are a SQL expert for %s databases. The user wants to query data.
|
||||
%s
|
||||
User request: %s
|
||||
|
||||
Please use the available tools to explore the database schema and generate the correct SQL query.
|
||||
- Use list_databases to see available databases
|
||||
- Use list_tables to see tables in the target database
|
||||
- Use describe_table to understand the table structure
|
||||
- Finally provide the SQL query as your Final Answer
|
||||
|
||||
Your Final Answer MUST be a valid JSON object with these fields:
|
||||
{"query": "<the SQL query>", "explanation": "<brief explanation in the user's language>"}`, dsType, dbContext, req.UserInput)
|
||||
}
|
||||
}
|
||||
|
||||
func buildQueryGeneratorInputs(req *AIChatRequest) map[string]string {
|
||||
inputs := map[string]string{
|
||||
"user_input": req.UserInput,
|
||||
}
|
||||
for _, key := range []string{"datasource_type", "datasource_id", "database_name", "table_name"} {
|
||||
if v := ctxStr(req.Context, key); v != "" {
|
||||
inputs[key] = v
|
||||
}
|
||||
}
|
||||
// datasource_id may be a number in JSON
|
||||
if inputs["datasource_id"] == "" {
|
||||
if id := ctxInt64(req.Context, "datasource_id"); id > 0 {
|
||||
inputs["datasource_id"] = fmt.Sprintf("%d", id)
|
||||
}
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
// --- generic handler ---
|
||||
|
||||
func (rt *Router) aiChat(c *gin.Context) {
|
||||
if !rt.Center.AIAgent.Enable {
|
||||
ginx.Bomb(http.StatusServiceUnavailable, "AI Agent is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
var req AIChatRequest
|
||||
ginx.BindJSON(c, &req)
|
||||
|
||||
if req.UserInput == "" {
|
||||
ginx.Bomb(http.StatusBadRequest, "user_input is required")
|
||||
return
|
||||
}
|
||||
if req.ActionKey == "" {
|
||||
ginx.Bomb(http.StatusBadRequest, "action_key is required")
|
||||
return
|
||||
}
|
||||
if req.Context == nil {
|
||||
req.Context = make(map[string]interface{})
|
||||
}
|
||||
|
||||
handler, ok := actionRegistry[req.ActionKey]
|
||||
if !ok {
|
||||
ginx.Bomb(http.StatusBadRequest, "unsupported action_key: %s", req.ActionKey)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("[AIChat] action=%s, user_input=%q", req.ActionKey, truncStr(req.UserInput, 100))
|
||||
|
||||
// Action-specific validation
|
||||
if handler.validate != nil {
|
||||
if err := handler.validate(&req); err != nil {
|
||||
ginx.Bomb(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Find AI agent by use_case
|
||||
agent, err := models.AIAgentGetByUseCase(rt.Ctx, handler.useCase)
|
||||
if err != nil || agent == nil {
|
||||
ginx.Bomb(http.StatusBadRequest, "no AI agent configured for use_case=%s", handler.useCase)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve LLM config
|
||||
llmCfg, err := models.AILLMConfigGetById(rt.Ctx, agent.LLMConfigId)
|
||||
if err != nil || llmCfg == nil {
|
||||
ginx.Bomb(http.StatusBadRequest, "referenced LLM config not found")
|
||||
return
|
||||
}
|
||||
agent.LLMConfig = llmCfg
|
||||
|
||||
// Select tools
|
||||
var tools []aiagent.AgentTool
|
||||
if handler.selectTools != nil {
|
||||
toolNames := handler.selectTools(&req)
|
||||
if toolNames != nil {
|
||||
tools = aiagent.GetBuiltinToolDefs(toolNames)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse extra config
|
||||
extraConfig := llmCfg.ExtraConfig
|
||||
|
||||
timeout := 120000
|
||||
if extraConfig.TimeoutSeconds > 0 {
|
||||
timeout = extraConfig.TimeoutSeconds * 1000
|
||||
}
|
||||
|
||||
// Build prompt
|
||||
userPrompt := ""
|
||||
if handler.buildPrompt != nil {
|
||||
userPrompt = handler.buildPrompt(&req)
|
||||
}
|
||||
|
||||
// Build workflow inputs
|
||||
inputs := map[string]string{"user_input": req.UserInput}
|
||||
if handler.buildInputs != nil {
|
||||
inputs = handler.buildInputs(&req)
|
||||
}
|
||||
|
||||
// Create agent
|
||||
agentCfg := aiagent.NewAgent(&aiagent.AIAgentConfig{
|
||||
Provider: llmCfg.APIType,
|
||||
LLMURL: llmCfg.APIURL,
|
||||
Model: llmCfg.Model,
|
||||
APIKey: llmCfg.APIKey,
|
||||
Headers: extraConfig.CustomHeaders,
|
||||
AgentMode: aiagent.AgentModeReAct,
|
||||
Tools: tools,
|
||||
Timeout: timeout,
|
||||
Stream: true,
|
||||
UserPromptTemplate: userPrompt,
|
||||
SkipSSLVerify: extraConfig.SkipTLSVerify,
|
||||
Proxy: extraConfig.Proxy,
|
||||
Temperature: extraConfig.Temperature,
|
||||
MaxTokens: extraConfig.MaxTokens,
|
||||
})
|
||||
|
||||
// Inject PromClient getter
|
||||
aiagent.SetPromClientGetter(func(dsId int64) prom.API {
|
||||
return rt.PromClients.GetCli(dsId)
|
||||
})
|
||||
|
||||
// Streaming setup
|
||||
streamChan := make(chan *models.StreamChunk, 100)
|
||||
wfCtx := &models.WorkflowContext{
|
||||
Stream: true,
|
||||
StreamChan: streamChan,
|
||||
Inputs: inputs,
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Errorf("[AIChat] PANIC in agent goroutine: %v", r)
|
||||
streamChan <- &models.StreamChunk{
|
||||
Type: models.StreamTypeError,
|
||||
Content: fmt.Sprintf("internal error: %v", r),
|
||||
Done: true,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
close(streamChan)
|
||||
}
|
||||
}()
|
||||
_, _, err := agentCfg.Process(rt.Ctx, wfCtx)
|
||||
if err != nil {
|
||||
logger.Errorf("[AIChat] agent Process error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Stream SSE events
|
||||
var accumulatedMessage string
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
chunk, ok := <-streamChan
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(chunk)
|
||||
|
||||
if chunk.Type == models.StreamTypeText || chunk.Type == models.StreamTypeThinking {
|
||||
if chunk.Delta != "" {
|
||||
accumulatedMessage += chunk.Delta
|
||||
} else if chunk.Content != "" {
|
||||
accumulatedMessage += chunk.Content
|
||||
}
|
||||
}
|
||||
|
||||
if chunk.Type == models.StreamTypeError {
|
||||
fmt.Fprintf(w, "event: error\ndata: %s\n\n", data)
|
||||
c.Writer.Flush()
|
||||
return false
|
||||
}
|
||||
|
||||
if chunk.Done || chunk.Type == models.StreamTypeDone {
|
||||
doneData := map[string]interface{}{
|
||||
"type": "done",
|
||||
"duration_ms": time.Since(startTime).Milliseconds(),
|
||||
"message": accumulatedMessage,
|
||||
"response": chunk.Content,
|
||||
}
|
||||
finalData, _ := json.Marshal(doneData)
|
||||
fmt.Fprintf(w, "event: done\ndata: %s\n\n", finalData)
|
||||
c.Writer.Flush()
|
||||
return false
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "event: chunk\ndata: %s\n\n", data)
|
||||
c.Writer.Flush()
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func truncStr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
// no param
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ctx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/loggrep"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// alertEvalDetailPage renders an HTML log viewer page for alert rule evaluation logs.
|
||||
func (rt *Router) alertEvalDetailPage(c *gin.Context) {
|
||||
id := ginx.UrlParamStr(c, "id")
|
||||
if !loggrep.IsValidRuleID(id) {
|
||||
c.String(http.StatusBadRequest, "invalid rule id format")
|
||||
return
|
||||
}
|
||||
|
||||
logs, instance, err := rt.getAlertEvalLogs(id)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
err = loggrep.RenderAlertEvalHTML(c.Writer, loggrep.AlertEvalPageData{
|
||||
RuleID: id,
|
||||
Instance: instance,
|
||||
Logs: logs,
|
||||
Total: len(logs),
|
||||
})
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "render error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// alertEvalDetailJSON returns JSON for alert rule evaluation logs.
|
||||
func (rt *Router) alertEvalDetailJSON(c *gin.Context) {
|
||||
id := ginx.UrlParamStr(c, "id")
|
||||
if !loggrep.IsValidRuleID(id) {
|
||||
ginx.Bomb(200, "invalid rule id format")
|
||||
}
|
||||
|
||||
logs, instance, err := rt.getAlertEvalLogs(id)
|
||||
ginx.Dangerous(err)
|
||||
|
||||
ginx.NewRender(c).Data(loggrep.EventDetailResp{
|
||||
Logs: logs,
|
||||
Instance: instance,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// getAlertEvalLogs resolves the target instance(s) and retrieves alert eval logs.
|
||||
func (rt *Router) getAlertEvalLogs(id string) ([]string, string, error) {
|
||||
ruleId, _ := strconv.ParseInt(id, 10, 64)
|
||||
rule, err := models.AlertRuleGetById(rt.Ctx, ruleId)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if rule == nil {
|
||||
return nil, "", fmt.Errorf("no such alert rule")
|
||||
}
|
||||
|
||||
instance := fmt.Sprintf("%s:%d", rt.Alert.Heartbeat.IP, rt.HTTP.Port)
|
||||
keyword := fmt.Sprintf("alert_eval_%s", id)
|
||||
|
||||
// Get datasource IDs for this rule
|
||||
dsIds := rt.DatasourceCache.GetIDsByDsCateAndQueries(rule.Cate, rule.DatasourceQueries)
|
||||
if len(dsIds) == 0 {
|
||||
// No datasources found (e.g. host rule), try local grep
|
||||
logs, err := loggrep.GrepLogDir(rt.LogDir, keyword)
|
||||
return logs, instance, err
|
||||
}
|
||||
|
||||
// Find unique target nodes via hash ring, with DB fallback
|
||||
nodeSet := make(map[string]struct{})
|
||||
for _, dsId := range dsIds {
|
||||
node, err := rt.getNodeForDatasource(dsId, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
nodeSet[node] = struct{}{}
|
||||
}
|
||||
|
||||
if len(nodeSet) == 0 {
|
||||
// Hash ring not ready, grep locally
|
||||
logs, err := loggrep.GrepLogDir(rt.LogDir, keyword)
|
||||
return logs, instance, err
|
||||
}
|
||||
|
||||
// Collect logs from all target nodes
|
||||
var allLogs []string
|
||||
var instances []string
|
||||
|
||||
for node := range nodeSet {
|
||||
if node == instance {
|
||||
logs, err := loggrep.GrepLogDir(rt.LogDir, keyword)
|
||||
if err == nil {
|
||||
allLogs = append(allLogs, logs...)
|
||||
instances = append(instances, node)
|
||||
}
|
||||
} else {
|
||||
logs, nodeAddr, err := rt.forwardAlertEvalDetail(node, id)
|
||||
if err == nil {
|
||||
allLogs = append(allLogs, logs...)
|
||||
instances = append(instances, nodeAddr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort logs by timestamp descending
|
||||
sort.Slice(allLogs, func(i, j int) bool {
|
||||
return allLogs[i] > allLogs[j]
|
||||
})
|
||||
|
||||
if len(allLogs) > loggrep.MaxLogLines {
|
||||
allLogs = allLogs[:loggrep.MaxLogLines]
|
||||
}
|
||||
|
||||
return allLogs, strings.Join(instances, ", "), nil
|
||||
}
|
||||
|
||||
func (rt *Router) forwardAlertEvalDetail(node, id string) ([]string, string, error) {
|
||||
url := fmt.Sprintf("http://%s/v1/n9e/alert-eval-detail/%s", node, id)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, node, err
|
||||
}
|
||||
|
||||
for user, pass := range rt.HTTP.APIForService.BasicAuth {
|
||||
req.SetBasicAuth(user, pass)
|
||||
break
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, node, fmt.Errorf("forward to %s failed: %v", node, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
|
||||
if err != nil {
|
||||
return nil, node, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Dat loggrep.EventDetailResp `json:"dat"`
|
||||
Err string `json:"err"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, node, err
|
||||
}
|
||||
if result.Err != "" {
|
||||
return nil, node, fmt.Errorf("%s", result.Err)
|
||||
}
|
||||
|
||||
return result.Dat.Logs, result.Dat.Instance, nil
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ctx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/alert/mute"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pushgw/pconf"
|
||||
"github.com/ccfos/nightingale/v6/pushgw/writer"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
"github.com/jinzhu/copier"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/prometheus/prometheus/prompb"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/i18n"
|
||||
)
|
||||
|
||||
@@ -882,28 +882,3 @@ func (rt *Router) batchAlertRuleClone(c *gin.Context) {
|
||||
|
||||
ginx.NewRender(c).Data(reterr, nil)
|
||||
}
|
||||
|
||||
func (rt *Router) timezonesGet(c *gin.Context) {
|
||||
// 返回常用时区列表(按时差去重,每个时差只保留一个代表性时区)
|
||||
timezones := []string{
|
||||
"Local",
|
||||
"UTC",
|
||||
"Asia/Shanghai", // UTC+8 (代表 Asia/Hong_Kong, Asia/Singapore 等)
|
||||
"Asia/Tokyo", // UTC+9 (代表 Asia/Seoul 等)
|
||||
"Asia/Dubai", // UTC+4
|
||||
"Asia/Kolkata", // UTC+5:30
|
||||
"Asia/Bangkok", // UTC+7 (代表 Asia/Jakarta 等)
|
||||
"Europe/London", // UTC+0 (代表 UTC)
|
||||
"Europe/Paris", // UTC+1 (代表 Europe/Berlin, Europe/Rome, Europe/Madrid 等)
|
||||
"Europe/Moscow", // UTC+3
|
||||
"America/New_York", // UTC-5 (代表 America/Toronto 等)
|
||||
"America/Chicago", // UTC-6 (代表 America/Mexico_City 等)
|
||||
"America/Denver", // UTC-7
|
||||
"America/Los_Angeles", // UTC-8
|
||||
"America/Sao_Paulo", // UTC-3
|
||||
"Australia/Sydney", // UTC+10 (代表 Australia/Melbourne 等)
|
||||
"Pacific/Auckland", // UTC+12
|
||||
}
|
||||
|
||||
ginx.NewRender(c).Data(timezones, nil)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/alert/common"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/i18n"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/i18n"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/file"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
"github.com/toolkits/pkg/runner"
|
||||
)
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ctx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package router
|
||||
import (
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/prom"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) metricFilterGets(c *gin.Context) {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/center/integration"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/i18n"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/ccfos/nightingale/v6/center/integration"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/i18n"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/storage"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/gin-gonic/gin"
|
||||
captcha "github.com/mojocn/base64Captcha"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) chartShareGets(c *gin.Context) {
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) notifyChannelsGets(c *gin.Context) {
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
const EMBEDDEDDASHBOARD = "embedded-dashboards"
|
||||
|
||||
@@ -2,9 +2,9 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/ccfos/nightingale/v6/pkg/secu"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
type confPropCrypto struct {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ctx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func checkAnnotationPermission(c *gin.Context, ctx *ctx.Context, dashboardId int64) {
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/datasource/opensearch"
|
||||
"github.com/ccfos/nightingale/v6/dskit/clickhouse"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/i18n"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/dscache"
|
||||
"github.com/ccfos/nightingale/v6/dskit/types"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
func (rt *Router) ShowDatabases(c *gin.Context) {
|
||||
@@ -18,7 +18,7 @@ func (rt *Router) ShowDatabases(c *gin.Context) {
|
||||
|
||||
plug, exists := dscache.DsCache.Get(f.Cate, f.DatasourceId)
|
||||
if !exists {
|
||||
logx.Warningf(c.Request.Context(), "cluster:%d not exists", f.DatasourceId)
|
||||
logger.Warningf("cluster:%d not exists", f.DatasourceId)
|
||||
ginx.Bomb(200, "cluster not exists")
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (rt *Router) ShowTables(c *gin.Context) {
|
||||
|
||||
plug, exists := dscache.DsCache.Get(f.Cate, f.DatasourceId)
|
||||
if !exists {
|
||||
logx.Warningf(c.Request.Context(), "cluster:%d not exists", f.DatasourceId)
|
||||
logger.Warningf("cluster:%d not exists", f.DatasourceId)
|
||||
ginx.Bomb(200, "cluster not exists")
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func (rt *Router) DescribeTable(c *gin.Context) {
|
||||
|
||||
plug, exists := dscache.DsCache.Get(f.Cate, f.DatasourceId)
|
||||
if !exists {
|
||||
logx.Warningf(c.Request.Context(), "cluster:%d not exists", f.DatasourceId)
|
||||
logger.Warningf("cluster:%d not exists", f.DatasourceId)
|
||||
ginx.Bomb(200, "cluster not exists")
|
||||
}
|
||||
// 只接受一个入参
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ctx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) embeddedProductGets(c *gin.Context) {
|
||||
|
||||
@@ -3,10 +3,10 @@ package router
|
||||
import (
|
||||
"github.com/ccfos/nightingale/v6/datasource/es"
|
||||
"github.com/ccfos/nightingale/v6/dscache"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
type IndexReq struct {
|
||||
@@ -34,7 +34,7 @@ func (rt *Router) QueryIndices(c *gin.Context) {
|
||||
|
||||
plug, exists := dscache.DsCache.Get(f.Cate, f.DatasourceId)
|
||||
if !exists {
|
||||
logx.Warningf(c.Request.Context(), "cluster:%d not exists", f.DatasourceId)
|
||||
logger.Warningf("cluster:%d not exists", f.DatasourceId)
|
||||
ginx.Bomb(200, "cluster not exists")
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func (rt *Router) QueryFields(c *gin.Context) {
|
||||
|
||||
plug, exists := dscache.DsCache.Get(f.Cate, f.DatasourceId)
|
||||
if !exists {
|
||||
logx.Warningf(c.Request.Context(), "cluster:%d not exists", f.DatasourceId)
|
||||
logger.Warningf("cluster:%d not exists", f.DatasourceId)
|
||||
ginx.Bomb(200, "cluster not exists")
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func (rt *Router) QueryESVariable(c *gin.Context) {
|
||||
|
||||
plug, exists := dscache.DsCache.Get(f.Cate, f.DatasourceId)
|
||||
if !exists {
|
||||
logx.Warningf(c.Request.Context(), "cluster:%d not exists", f.DatasourceId)
|
||||
logger.Warningf("cluster:%d not exists", f.DatasourceId)
|
||||
ginx.Bomb(200, "cluster not exists")
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
// 创建 ES Index Pattern
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/alert/naming"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/loggrep"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
// eventDetailPage renders an HTML log viewer page (for pages group).
|
||||
@@ -58,33 +58,6 @@ func (rt *Router) eventDetailJSON(c *gin.Context) {
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// getNodeForDatasource returns the alert engine instance responsible for the given
|
||||
// datasource and primary key. It first checks the local hashring, and falls back
|
||||
// to querying the database for active instances if the hashring is empty
|
||||
// (e.g. when the datasource belongs to another engine cluster).
|
||||
func (rt *Router) getNodeForDatasource(datasourceId int64, pk string) (string, error) {
|
||||
dsIdStr := strconv.FormatInt(datasourceId, 10)
|
||||
node, err := naming.DatasourceHashRing.GetNode(dsIdStr, pk)
|
||||
if err == nil {
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// Hashring is empty for this datasource (likely belongs to another engine cluster).
|
||||
// Query the DB for active instances.
|
||||
servers, dbErr := models.AlertingEngineGetsInstances(rt.Ctx,
|
||||
"datasource_id = ? and clock > ?",
|
||||
datasourceId, time.Now().Unix()-30)
|
||||
if dbErr != nil {
|
||||
return "", dbErr
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
return "", fmt.Errorf("no active instances for datasource %d", datasourceId)
|
||||
}
|
||||
|
||||
ring := naming.NewConsistentHashRing(int32(naming.NodeReplicas), servers)
|
||||
return ring.Get(pk)
|
||||
}
|
||||
|
||||
// getEventLogs resolves the target instance and retrieves logs.
|
||||
func (rt *Router) getEventLogs(hash string) ([]string, string, error) {
|
||||
event, err := models.AlertHisEventGetByHash(rt.Ctx, hash)
|
||||
@@ -95,11 +68,12 @@ func (rt *Router) getEventLogs(hash string) ([]string, string, error) {
|
||||
return nil, "", fmt.Errorf("no such alert event")
|
||||
}
|
||||
|
||||
dsId := strconv.FormatInt(event.DatasourceId, 10)
|
||||
ruleId := strconv.FormatInt(event.RuleId, 10)
|
||||
|
||||
instance := fmt.Sprintf("%s:%d", rt.Alert.Heartbeat.IP, rt.HTTP.Port)
|
||||
|
||||
node, err := rt.getNodeForDatasource(event.DatasourceId, ruleId)
|
||||
node, err := naming.DatasourceHashRing.GetNode(dsId, ruleId)
|
||||
if err != nil || node == instance {
|
||||
// hashring not ready or target is self, handle locally
|
||||
logs, err := loggrep.GrepLogDir(rt.LogDir, hash)
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/alert/pipeline/engine"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/i18n"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ctx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
const defaultLimit = 300
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ctx"
|
||||
"github.com/ccfos/nightingale/v6/pushgw/idents"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
|
||||
@@ -14,16 +14,16 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/pkg/dingtalk"
|
||||
"github.com/ccfos/nightingale/v6/pkg/feishu"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ldapx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/oauth2x"
|
||||
"github.com/ccfos/nightingale/v6/pkg/oidcx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/secu"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -37,9 +37,7 @@ type loginForm struct {
|
||||
func (rt *Router) loginPost(c *gin.Context) {
|
||||
var f loginForm
|
||||
ginx.BindJSON(c, &f)
|
||||
|
||||
rctx := c.Request.Context()
|
||||
logx.Infof(rctx, "username:%s login from:%s", f.Username, c.ClientIP())
|
||||
logger.Infof("username:%s login from:%s", f.Username, c.ClientIP())
|
||||
|
||||
if rt.HTTP.ShowCaptcha.Enable {
|
||||
if !CaptchaVerify(f.Captchaid, f.Verifyvalue) {
|
||||
@@ -52,25 +50,23 @@ func (rt *Router) loginPost(c *gin.Context) {
|
||||
if rt.HTTP.RSA.OpenRSA {
|
||||
decPassWord, err := secu.Decrypt(f.Password, rt.HTTP.RSA.RSAPrivateKey, rt.HTTP.RSA.RSAPassWord)
|
||||
if err != nil {
|
||||
logx.Errorf(rctx, "RSA Decrypt failed: %v username: %s", err, f.Username)
|
||||
logger.Errorf("RSA Decrypt failed: %v username: %s", err, f.Username)
|
||||
ginx.NewRender(c).Message(err)
|
||||
return
|
||||
}
|
||||
authPassWord = decPassWord
|
||||
}
|
||||
|
||||
reqCtx := rt.Ctx.WithContext(rctx)
|
||||
|
||||
var user *models.User
|
||||
var err error
|
||||
lc := rt.Sso.LDAP.Copy()
|
||||
if lc.Enable {
|
||||
user, err = ldapx.LdapLogin(reqCtx, f.Username, authPassWord, lc.DefaultRoles, lc.DefaultTeams, lc)
|
||||
user, err = ldapx.LdapLogin(rt.Ctx, f.Username, authPassWord, lc.DefaultRoles, lc.DefaultTeams, lc)
|
||||
if err != nil {
|
||||
logx.Debugf(rctx, "ldap login failed: %v username: %s", err, f.Username)
|
||||
logger.Debugf("ldap login failed: %v username: %s", err, f.Username)
|
||||
var errLoginInN9e error
|
||||
// to use n9e as the minimum guarantee for login
|
||||
if user, errLoginInN9e = models.PassLogin(reqCtx, rt.Redis, f.Username, authPassWord); errLoginInN9e != nil {
|
||||
if user, errLoginInN9e = models.PassLogin(rt.Ctx, rt.Redis, f.Username, authPassWord); errLoginInN9e != nil {
|
||||
ginx.NewRender(c).Message("ldap login failed: %v; n9e login failed: %v", err, errLoginInN9e)
|
||||
return
|
||||
}
|
||||
@@ -78,7 +74,7 @@ func (rt *Router) loginPost(c *gin.Context) {
|
||||
user.RolesLst = strings.Fields(user.Roles)
|
||||
}
|
||||
} else {
|
||||
user, err = models.PassLogin(reqCtx, rt.Redis, f.Username, authPassWord)
|
||||
user, err = models.PassLogin(rt.Ctx, rt.Redis, f.Username, authPassWord)
|
||||
ginx.Dangerous(err)
|
||||
}
|
||||
|
||||
@@ -102,8 +98,7 @@ func (rt *Router) loginPost(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (rt *Router) logoutPost(c *gin.Context) {
|
||||
rctx := c.Request.Context()
|
||||
logx.Infof(rctx, "username:%s logout from:%s", c.GetString("username"), c.ClientIP())
|
||||
logger.Infof("username:%s logout from:%s", c.GetString("username"), c.ClientIP())
|
||||
metadata, err := rt.extractTokenMetadata(c.Request)
|
||||
if err != nil {
|
||||
ginx.NewRender(c, http.StatusBadRequest).Message("failed to parse jwt token")
|
||||
@@ -122,7 +117,7 @@ func (rt *Router) logoutPost(c *gin.Context) {
|
||||
// 获取用户的 id_token
|
||||
idToken, err := rt.fetchIdToken(c.Request.Context(), user.Id)
|
||||
if err != nil {
|
||||
logx.Debugf(rctx, "fetch id_token failed: %v, user_id: %d", err, user.Id)
|
||||
logger.Debugf("fetch id_token failed: %v, user_id: %d", err, user.Id)
|
||||
idToken = "" // 如果获取失败,使用空字符串
|
||||
}
|
||||
|
||||
@@ -225,7 +220,7 @@ func (rt *Router) refreshPost(c *gin.Context) {
|
||||
// 注意:这里不会获取新的 id_token,只是延长 Redis 中现有 id_token 的 TTL
|
||||
if idToken, err := rt.fetchIdToken(c.Request.Context(), userid); err == nil && idToken != "" {
|
||||
if err := rt.saveIdToken(c.Request.Context(), userid, idToken); err != nil {
|
||||
logx.Debugf(c.Request.Context(), "refresh id_token ttl failed: %v, user_id: %d", err, userid)
|
||||
logger.Debugf("refresh id_token ttl failed: %v, user_id: %d", err, userid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,13 +271,12 @@ type CallbackOutput struct {
|
||||
}
|
||||
|
||||
func (rt *Router) loginCallback(c *gin.Context) {
|
||||
rctx := c.Request.Context()
|
||||
code := ginx.QueryStr(c, "code", "")
|
||||
state := ginx.QueryStr(c, "state", "")
|
||||
|
||||
ret, err := rt.Sso.OIDC.Callback(rt.Redis, rctx, code, state)
|
||||
ret, err := rt.Sso.OIDC.Callback(rt.Redis, c.Request.Context(), code, state)
|
||||
if err != nil {
|
||||
logx.Errorf(rctx, "sso_callback fail. code:%s, state:%s, get ret: %+v. error: %v", code, state, ret, err)
|
||||
logger.Errorf("sso_callback fail. code:%s, state:%s, get ret: %+v. error: %v", code, state, ret, err)
|
||||
ginx.NewRender(c).Data(CallbackOutput{}, err)
|
||||
return
|
||||
}
|
||||
@@ -305,7 +299,7 @@ func (rt *Router) loginCallback(c *gin.Context) {
|
||||
for _, gid := range rt.Sso.OIDC.DefaultTeams {
|
||||
err = models.UserGroupMemberAdd(rt.Ctx, gid, user.Id)
|
||||
if err != nil {
|
||||
logx.Errorf(rctx, "user:%v UserGroupMemberAdd: %s", user, err)
|
||||
logger.Errorf("user:%v UserGroupMemberAdd: %s", user, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,12 +309,12 @@ func (rt *Router) loginCallback(c *gin.Context) {
|
||||
userIdentity := fmt.Sprintf("%d-%s", user.Id, user.Username)
|
||||
ts, err := rt.createTokens(rt.HTTP.JWTAuth.SigningKey, userIdentity)
|
||||
ginx.Dangerous(err)
|
||||
ginx.Dangerous(rt.createAuth(rctx, userIdentity, ts))
|
||||
ginx.Dangerous(rt.createAuth(c.Request.Context(), userIdentity, ts))
|
||||
|
||||
// 保存 id_token 到 Redis,用于登出时使用
|
||||
if ret.IdToken != "" {
|
||||
if err := rt.saveIdToken(rctx, user.Id, ret.IdToken); err != nil {
|
||||
logx.Errorf(rctx, "save id_token failed: %v, user_id: %d", err, user.Id)
|
||||
if err := rt.saveIdToken(c.Request.Context(), user.Id, ret.IdToken); err != nil {
|
||||
logger.Errorf("save id_token failed: %v, user_id: %d", err, user.Id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +355,7 @@ func (rt *Router) loginRedirectCas(c *gin.Context) {
|
||||
}
|
||||
|
||||
if !rt.Sso.CAS.Enable {
|
||||
logx.Errorf(c.Request.Context(), "cas is not enable")
|
||||
logger.Error("cas is not enable")
|
||||
ginx.NewRender(c).Data("", nil)
|
||||
return
|
||||
}
|
||||
@@ -376,18 +370,17 @@ func (rt *Router) loginRedirectCas(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (rt *Router) loginCallbackCas(c *gin.Context) {
|
||||
rctx := c.Request.Context()
|
||||
ticket := ginx.QueryStr(c, "ticket", "")
|
||||
state := ginx.QueryStr(c, "state", "")
|
||||
ret, err := rt.Sso.CAS.ValidateServiceTicket(rctx, ticket, state, rt.Redis)
|
||||
ret, err := rt.Sso.CAS.ValidateServiceTicket(c.Request.Context(), ticket, state, rt.Redis)
|
||||
if err != nil {
|
||||
logx.Errorf(rctx, "ValidateServiceTicket: %s", err)
|
||||
logger.Errorf("ValidateServiceTicket: %s", err)
|
||||
ginx.NewRender(c).Data("", err)
|
||||
return
|
||||
}
|
||||
user, err := models.UserGet(rt.Ctx, "username=?", ret.Username)
|
||||
if err != nil {
|
||||
logx.Errorf(rctx, "UserGet: %s", err)
|
||||
logger.Errorf("UserGet: %s", err)
|
||||
}
|
||||
ginx.Dangerous(err)
|
||||
if user != nil {
|
||||
@@ -406,10 +399,10 @@ func (rt *Router) loginCallbackCas(c *gin.Context) {
|
||||
userIdentity := fmt.Sprintf("%d-%s", user.Id, user.Username)
|
||||
ts, err := rt.createTokens(rt.HTTP.JWTAuth.SigningKey, userIdentity)
|
||||
if err != nil {
|
||||
logx.Errorf(rctx, "createTokens: %s", err)
|
||||
logger.Errorf("createTokens: %s", err)
|
||||
}
|
||||
ginx.Dangerous(err)
|
||||
ginx.Dangerous(rt.createAuth(rctx, userIdentity, ts))
|
||||
ginx.Dangerous(rt.createAuth(c.Request.Context(), userIdentity, ts))
|
||||
|
||||
redirect := "/"
|
||||
if ret.Redirect != "/login" {
|
||||
@@ -482,13 +475,12 @@ func (rt *Router) loginRedirectDingTalk(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (rt *Router) loginCallbackDingTalk(c *gin.Context) {
|
||||
rctx := c.Request.Context()
|
||||
code := ginx.QueryStr(c, "code", "")
|
||||
state := ginx.QueryStr(c, "state", "")
|
||||
|
||||
ret, err := rt.Sso.DingTalk.Callback(rt.Redis, rctx, code, state)
|
||||
ret, err := rt.Sso.DingTalk.Callback(rt.Redis, c.Request.Context(), code, state)
|
||||
if err != nil {
|
||||
logx.Errorf(rctx, "sso_callback DingTalk fail. code:%s, state:%s, get ret: %+v. error: %v", code, state, ret, err)
|
||||
logger.Errorf("sso_callback DingTalk fail. code:%s, state:%s, get ret: %+v. error: %v", code, state, ret, err)
|
||||
ginx.NewRender(c).Data(CallbackOutput{}, err)
|
||||
return
|
||||
}
|
||||
@@ -558,13 +550,12 @@ func (rt *Router) loginRedirectFeiShu(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (rt *Router) loginCallbackFeiShu(c *gin.Context) {
|
||||
rctx := c.Request.Context()
|
||||
code := ginx.QueryStr(c, "code", "")
|
||||
state := ginx.QueryStr(c, "state", "")
|
||||
|
||||
ret, err := rt.Sso.FeiShu.Callback(rt.Redis, rctx, code, state)
|
||||
ret, err := rt.Sso.FeiShu.Callback(rt.Redis, c.Request.Context(), code, state)
|
||||
if err != nil {
|
||||
logx.Errorf(rctx, "sso_callback FeiShu fail. code:%s, state:%s, get ret: %+v. error: %v", code, state, ret, err)
|
||||
logger.Errorf("sso_callback FeiShu fail. code:%s, state:%s, get ret: %+v. error: %v", code, state, ret, err)
|
||||
ginx.NewRender(c).Data(CallbackOutput{}, err)
|
||||
return
|
||||
}
|
||||
@@ -580,22 +571,12 @@ func (rt *Router) loginCallbackFeiShu(c *gin.Context) {
|
||||
} else {
|
||||
user = new(models.User)
|
||||
defaultRoles := []string{}
|
||||
defaultUserGroups := []int64{}
|
||||
if rt.Sso.FeiShu != nil && rt.Sso.FeiShu.FeiShuConfig != nil {
|
||||
defaultRoles = rt.Sso.FeiShu.FeiShuConfig.DefaultRoles
|
||||
defaultUserGroups = rt.Sso.FeiShu.FeiShuConfig.DefaultUserGroups
|
||||
}
|
||||
|
||||
user.FullSsoFields(feishu.SsoTypeName, ret.Username, ret.Nickname, ret.Phone, ret.Email, defaultRoles)
|
||||
// create user from feishu
|
||||
ginx.Dangerous(user.Add(rt.Ctx))
|
||||
|
||||
if len(defaultUserGroups) > 0 {
|
||||
err = user.AddToUserGroups(rt.Ctx, defaultUserGroups)
|
||||
if err != nil {
|
||||
logx.Errorf(rctx, "sso feishu add user group error %v %v", ret, err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// set user login state
|
||||
@@ -619,13 +600,12 @@ func (rt *Router) loginCallbackFeiShu(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (rt *Router) loginCallbackOAuth(c *gin.Context) {
|
||||
rctx := c.Request.Context()
|
||||
code := ginx.QueryStr(c, "code", "")
|
||||
state := ginx.QueryStr(c, "state", "")
|
||||
|
||||
ret, err := rt.Sso.OAuth2.Callback(rt.Redis, rctx, code, state)
|
||||
ret, err := rt.Sso.OAuth2.Callback(rt.Redis, c.Request.Context(), code, state)
|
||||
if err != nil {
|
||||
logx.Debugf(rctx, "sso.callback() get ret %+v error %v", ret, err)
|
||||
logger.Debugf("sso.callback() get ret %+v error %v", ret, err)
|
||||
ginx.NewRender(c).Data(CallbackOutput{}, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/pkg/slice"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/tplx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) messageTemplatesAdd(c *gin.Context) {
|
||||
|
||||
@@ -2,9 +2,9 @@ package router
|
||||
|
||||
import (
|
||||
"github.com/ccfos/nightingale/v6/center/cconf"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) metricsDescGetFile(c *gin.Context) {
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
// no param
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/alert/mute"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/i18n"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/center/cstats"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/alert/sender"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ctx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) notifyChannelsAdd(c *gin.Context) {
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/memsto"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/tplx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/str"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ctx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/slice"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/center/cconf"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/tplx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/str"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ package router
|
||||
import (
|
||||
"github.com/ccfos/nightingale/v6/datasource/opensearch"
|
||||
"github.com/ccfos/nightingale/v6/dscache"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
|
||||
@@ -12,13 +12,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/poster"
|
||||
pkgprom "github.com/ccfos/nightingale/v6/pkg/prom"
|
||||
"github.com/ccfos/nightingale/v6/prom"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
"github.com/toolkits/pkg/net/httplib"
|
||||
)
|
||||
@@ -39,16 +38,15 @@ func (rt *Router) promBatchQueryRange(c *gin.Context) {
|
||||
var f BatchQueryForm
|
||||
ginx.Dangerous(c.BindJSON(&f))
|
||||
|
||||
lst, err := PromBatchQueryRange(c.Request.Context(), rt.PromClients, f)
|
||||
lst, err := PromBatchQueryRange(rt.PromClients, f)
|
||||
ginx.NewRender(c).Data(lst, err)
|
||||
}
|
||||
|
||||
func PromBatchQueryRange(ctx context.Context, pc *prom.PromClientMap, f BatchQueryForm) ([]model.Value, error) {
|
||||
func PromBatchQueryRange(pc *prom.PromClientMap, f BatchQueryForm) ([]model.Value, error) {
|
||||
var lst []model.Value
|
||||
|
||||
cli := pc.GetCli(f.DatasourceId)
|
||||
if cli == nil {
|
||||
logx.Warningf(ctx, "no such datasource id: %d", f.DatasourceId)
|
||||
return lst, fmt.Errorf("no such datasource id: %d", f.DatasourceId)
|
||||
}
|
||||
|
||||
@@ -59,9 +57,8 @@ func PromBatchQueryRange(ctx context.Context, pc *prom.PromClientMap, f BatchQue
|
||||
Step: time.Duration(item.Step) * time.Second,
|
||||
}
|
||||
|
||||
resp, _, err := cli.QueryRange(ctx, item.Query, r)
|
||||
resp, _, err := cli.QueryRange(context.Background(), item.Query, r)
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query range error: query:%s err:%v", item.Query, err)
|
||||
return lst, err
|
||||
}
|
||||
|
||||
@@ -84,23 +81,22 @@ func (rt *Router) promBatchQueryInstant(c *gin.Context) {
|
||||
var f BatchInstantForm
|
||||
ginx.Dangerous(c.BindJSON(&f))
|
||||
|
||||
lst, err := PromBatchQueryInstant(c.Request.Context(), rt.PromClients, f)
|
||||
lst, err := PromBatchQueryInstant(rt.PromClients, f)
|
||||
ginx.NewRender(c).Data(lst, err)
|
||||
}
|
||||
|
||||
func PromBatchQueryInstant(ctx context.Context, pc *prom.PromClientMap, f BatchInstantForm) ([]model.Value, error) {
|
||||
func PromBatchQueryInstant(pc *prom.PromClientMap, f BatchInstantForm) ([]model.Value, error) {
|
||||
var lst []model.Value
|
||||
|
||||
cli := pc.GetCli(f.DatasourceId)
|
||||
if cli == nil {
|
||||
logx.Warningf(ctx, "no such datasource id: %d", f.DatasourceId)
|
||||
logger.Warningf("no such datasource id: %d", f.DatasourceId)
|
||||
return lst, fmt.Errorf("no such datasource id: %d", f.DatasourceId)
|
||||
}
|
||||
|
||||
for _, item := range f.Queries {
|
||||
resp, _, err := cli.Query(ctx, item.Query, time.Unix(item.Time, 0))
|
||||
resp, _, err := cli.Query(context.Background(), item.Query, time.Unix(item.Time, 0))
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query instant error: query:%s err:%v", item.Query, err)
|
||||
return lst, err
|
||||
}
|
||||
|
||||
@@ -193,7 +189,7 @@ func (rt *Router) dsProxy(c *gin.Context) {
|
||||
|
||||
modifyResponse := func(r *http.Response) error {
|
||||
if r.StatusCode == http.StatusUnauthorized {
|
||||
logx.Warningf(c.Request.Context(), "proxy path:%s unauthorized access ", c.Request.URL.Path)
|
||||
logger.Warningf("proxy path:%s unauthorized access ", c.Request.URL.Path)
|
||||
return fmt.Errorf("unauthorized access")
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/alert/eval"
|
||||
"github.com/ccfos/nightingale/v6/dscache"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
type CheckDsPermFunc func(c *gin.Context, dsId int64, cate string, q interface{}) bool
|
||||
@@ -47,7 +47,6 @@ func QueryLogBatchConcurrently(anonymousAccess bool, ctx *gin.Context, f QueryFr
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
var errs []error
|
||||
rctx := ctx.Request.Context()
|
||||
|
||||
for _, q := range f.Queries {
|
||||
if !anonymousAccess && !CheckDsPerm(ctx, q.Did, q.DsCate, q) {
|
||||
@@ -56,14 +55,14 @@ func QueryLogBatchConcurrently(anonymousAccess bool, ctx *gin.Context, f QueryFr
|
||||
|
||||
plug, exists := dscache.DsCache.Get(q.DsCate, q.Did)
|
||||
if !exists {
|
||||
logx.Warningf(rctx, "cluster:%d not exists query:%+v", q.Did, q)
|
||||
logger.Warningf("cluster:%d not exists query:%+v", q.Did, q)
|
||||
return LogResp{}, fmt.Errorf("cluster not exists")
|
||||
}
|
||||
|
||||
// 根据数据源类型对 Query 进行模板渲染处理
|
||||
err := eval.ExecuteQueryTemplate(q.DsCate, q.Query, nil)
|
||||
if err != nil {
|
||||
logx.Warningf(rctx, "query template execute error: %v", err)
|
||||
logger.Warningf("query template execute error: %v", err)
|
||||
return LogResp{}, fmt.Errorf("query template execute error: %v", err)
|
||||
}
|
||||
|
||||
@@ -71,12 +70,12 @@ func QueryLogBatchConcurrently(anonymousAccess bool, ctx *gin.Context, f QueryFr
|
||||
go func(query Query) {
|
||||
defer wg.Done()
|
||||
|
||||
data, total, err := plug.QueryLog(rctx, query.Query)
|
||||
data, total, err := plug.QueryLog(ctx.Request.Context(), query.Query)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("query data error: %v query:%v\n ", err, query)
|
||||
logx.Warningf(rctx, "%s", errMsg)
|
||||
logger.Warningf(errMsg)
|
||||
errs = append(errs, err)
|
||||
return
|
||||
}
|
||||
@@ -122,7 +121,6 @@ func QueryDataConcurrently(anonymousAccess bool, ctx *gin.Context, f models.Quer
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
var errs []error
|
||||
rctx := ctx.Request.Context()
|
||||
|
||||
for _, q := range f.Queries {
|
||||
if !anonymousAccess && !CheckDsPerm(ctx, f.DatasourceId, f.Cate, q) {
|
||||
@@ -131,7 +129,7 @@ func QueryDataConcurrently(anonymousAccess bool, ctx *gin.Context, f models.Quer
|
||||
|
||||
plug, exists := dscache.DsCache.Get(f.Cate, f.DatasourceId)
|
||||
if !exists {
|
||||
logx.Warningf(rctx, "cluster:%d not exists", f.DatasourceId)
|
||||
logger.Warningf("cluster:%d not exists", f.DatasourceId)
|
||||
return nil, fmt.Errorf("cluster not exists")
|
||||
}
|
||||
|
||||
@@ -139,16 +137,16 @@ func QueryDataConcurrently(anonymousAccess bool, ctx *gin.Context, f models.Quer
|
||||
go func(query interface{}) {
|
||||
defer wg.Done()
|
||||
|
||||
data, err := plug.QueryData(rctx, query)
|
||||
data, err := plug.QueryData(ctx.Request.Context(), query)
|
||||
if err != nil {
|
||||
logx.Warningf(rctx, "query data error: req:%+v err:%v", query, err)
|
||||
logger.Warningf("query data error: req:%+v err:%v", query, err)
|
||||
mu.Lock()
|
||||
errs = append(errs, err)
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
logx.Debugf(rctx, "query data: req:%+v resp:%+v", query, data)
|
||||
logger.Debugf("query data: req:%+v resp:%+v", query, data)
|
||||
mu.Lock()
|
||||
resp = append(resp, data...)
|
||||
mu.Unlock()
|
||||
@@ -194,7 +192,6 @@ func QueryLogConcurrently(anonymousAccess bool, ctx *gin.Context, f models.Query
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
var errs []error
|
||||
rctx := ctx.Request.Context()
|
||||
|
||||
for _, q := range f.Queries {
|
||||
if !anonymousAccess && !CheckDsPerm(ctx, f.DatasourceId, f.Cate, q) {
|
||||
@@ -203,7 +200,7 @@ func QueryLogConcurrently(anonymousAccess bool, ctx *gin.Context, f models.Query
|
||||
|
||||
plug, exists := dscache.DsCache.Get(f.Cate, f.DatasourceId)
|
||||
if !exists {
|
||||
logx.Warningf(rctx, "cluster:%d not exists query:%+v", f.DatasourceId, f)
|
||||
logger.Warningf("cluster:%d not exists query:%+v", f.DatasourceId, f)
|
||||
return LogResp{}, fmt.Errorf("cluster not exists")
|
||||
}
|
||||
|
||||
@@ -211,11 +208,11 @@ func QueryLogConcurrently(anonymousAccess bool, ctx *gin.Context, f models.Query
|
||||
go func(query interface{}) {
|
||||
defer wg.Done()
|
||||
|
||||
data, total, err := plug.QueryLog(rctx, query)
|
||||
logx.Debugf(rctx, "query log: req:%+v resp:%+v", query, data)
|
||||
data, total, err := plug.QueryLog(ctx.Request.Context(), query)
|
||||
logger.Debugf("query log: req:%+v resp:%+v", query, data)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("query data error: %v query:%v\n ", err, query)
|
||||
logx.Warningf(rctx, "%s", errMsg)
|
||||
logger.Warningf(errMsg)
|
||||
mu.Lock()
|
||||
errs = append(errs, err)
|
||||
mu.Unlock()
|
||||
@@ -253,7 +250,6 @@ func (rt *Router) QueryLogV2(c *gin.Context) {
|
||||
func (rt *Router) QueryLog(c *gin.Context) {
|
||||
var f models.QueryParam
|
||||
ginx.BindJSON(c, &f)
|
||||
rctx := c.Request.Context()
|
||||
|
||||
var resp []interface{}
|
||||
for _, q := range f.Queries {
|
||||
@@ -263,13 +259,13 @@ func (rt *Router) QueryLog(c *gin.Context) {
|
||||
|
||||
plug, exists := dscache.DsCache.Get("elasticsearch", f.DatasourceId)
|
||||
if !exists {
|
||||
logx.Warningf(rctx, "cluster:%d not exists", f.DatasourceId)
|
||||
logger.Warningf("cluster:%d not exists", f.DatasourceId)
|
||||
ginx.Bomb(200, "cluster not exists")
|
||||
}
|
||||
|
||||
data, _, err := plug.QueryLog(rctx, q)
|
||||
data, _, err := plug.QueryLog(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
logx.Warningf(rctx, "query data error: %v", err)
|
||||
logger.Warningf("query data error: %v", err)
|
||||
ginx.Bomb(200, "err:%v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) recordingRuleGets(c *gin.Context) {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/center/cconf"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) rolesGets(c *gin.Context) {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/center/cconf"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/i18n"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/slice"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) savedViewGets(c *gin.Context) {
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/pkg/flashduty"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ormx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/secu"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) serversGet(c *gin.Context) {
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
// sourceTokenAdd 生成新的源令牌
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pushgw/idents"
|
||||
"github.com/ccfos/nightingale/v6/storage"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/alert/sender"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/i18n"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/i18n"
|
||||
"github.com/toolkits/pkg/str"
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/datasource/tdengine"
|
||||
"github.com/ccfos/nightingale/v6/dscache"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
type databasesQueryForm struct {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/loggrep"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// traceLogsPage renders an HTML log viewer page for trace logs.
|
||||
func (rt *Router) traceLogsPage(c *gin.Context) {
|
||||
traceId := ginx.UrlParamStr(c, "traceid")
|
||||
if !loggrep.IsValidTraceID(traceId) {
|
||||
c.String(http.StatusBadRequest, "invalid trace id format")
|
||||
return
|
||||
}
|
||||
|
||||
logs, instance, err := rt.getTraceLogs(traceId)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
err = loggrep.RenderTraceLogsHTML(c.Writer, loggrep.TraceLogsPageData{
|
||||
TraceID: traceId,
|
||||
Instance: instance,
|
||||
Logs: logs,
|
||||
Total: len(logs),
|
||||
})
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "render error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// traceLogsJSON returns JSON for trace logs.
|
||||
func (rt *Router) traceLogsJSON(c *gin.Context) {
|
||||
traceId := ginx.UrlParamStr(c, "traceid")
|
||||
if !loggrep.IsValidTraceID(traceId) {
|
||||
ginx.Bomb(200, "invalid trace id format")
|
||||
}
|
||||
|
||||
logs, instance, err := rt.getTraceLogs(traceId)
|
||||
ginx.Dangerous(err)
|
||||
|
||||
ginx.NewRender(c).Data(loggrep.EventDetailResp{
|
||||
Logs: logs,
|
||||
Instance: instance,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// getTraceLogs finds the same-engine instances and queries each one
|
||||
// until trace logs are found. Trace logs belong to a single instance.
|
||||
func (rt *Router) getTraceLogs(traceId string) ([]string, string, error) {
|
||||
keyword := "trace_id=" + traceId
|
||||
instance := fmt.Sprintf("%s:%d", rt.Alert.Heartbeat.IP, rt.HTTP.Port)
|
||||
engineName := rt.Alert.Heartbeat.EngineName
|
||||
|
||||
// try local first
|
||||
logs, err := loggrep.GrepLatestLogFiles(rt.LogDir, keyword)
|
||||
if err == nil && len(logs) > 0 {
|
||||
return logs, instance, nil
|
||||
}
|
||||
|
||||
// find all instances with the same engineName
|
||||
servers, err := models.AlertingEngineGetsInstances(rt.Ctx,
|
||||
"engine_cluster = ? and clock > ?",
|
||||
engineName, time.Now().Unix()-30)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// loop through remote instances until we find logs
|
||||
for _, node := range servers {
|
||||
if node == instance {
|
||||
continue // already tried local
|
||||
}
|
||||
|
||||
logs, nodeAddr, err := rt.forwardTraceLogs(node, traceId)
|
||||
if err != nil {
|
||||
logger.Errorf("forwardTraceLogs failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if len(logs) > 0 {
|
||||
return logs, nodeAddr, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, instance, nil
|
||||
}
|
||||
|
||||
func (rt *Router) forwardTraceLogs(node, traceId string) ([]string, string, error) {
|
||||
url := fmt.Sprintf("http://%s/v1/n9e/trace-logs/%s", node, traceId)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, node, err
|
||||
}
|
||||
|
||||
for user, pass := range rt.HTTP.APIForService.BasicAuth {
|
||||
req.SetBasicAuth(user, pass)
|
||||
break
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, node, fmt.Errorf("forward to %s failed: %v", node, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
|
||||
if err != nil {
|
||||
return nil, node, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Dat loggrep.EventDetailResp `json:"dat"`
|
||||
Err string `json:"err"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, node, err
|
||||
}
|
||||
if result.Err != "" {
|
||||
return nil, node, fmt.Errorf("%s", result.Err)
|
||||
}
|
||||
|
||||
return result.Dat.Logs, result.Dat.Instance, nil
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/pkg/flashduty"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ormx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/secu"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/flashduty"
|
||||
"github.com/ccfos/nightingale/v6/pkg/strx"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/ginx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/toolkits/pkg/ginx"
|
||||
)
|
||||
|
||||
func (rt *Router) userVariableConfigGets(context *gin.Context) {
|
||||
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -180,9 +178,14 @@ func (c *Clickhouse) QueryData(ctx context.Context, query interface{}) ([]models
|
||||
|
||||
rows, err := c.QueryTimeseries(ctx, ckQueryParam)
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query:%+v get data err:%v", ckQueryParam, err)
|
||||
logger.Warningf("query:%+v get data err:%v", ckQueryParam, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Warningf("query:%+v get data err:%v", ckQueryParam, err)
|
||||
return []models.DataResp{}, err
|
||||
}
|
||||
data := make([]models.DataResp, 0)
|
||||
for i := range rows {
|
||||
data = append(data, models.DataResp{
|
||||
@@ -211,7 +214,7 @@ func (c *Clickhouse) QueryLog(ctx context.Context, query interface{}) ([]interfa
|
||||
|
||||
rows, err := c.Query(ctx, ckQueryParam)
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query:%+v get data err:%v", ckQueryParam, err)
|
||||
logger.Warningf("query:%+v get data err:%v", ckQueryParam, err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
"github.com/ccfos/nightingale/v6/memsto"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
)
|
||||
|
||||
type FixedField string
|
||||
@@ -544,7 +543,7 @@ func QueryData(ctx context.Context, queryParam interface{}, cliTimeout int64, ve
|
||||
|
||||
source, _ := queryString.Source()
|
||||
b, _ := json.Marshal(source)
|
||||
logx.Debugf(ctx, "query_data q:%+v indexArr:%+v tsAggr:%+v query_string:%s", param, indexArr, tsAggr, string(b))
|
||||
logger.Debugf("query_data q:%+v indexArr:%+v tsAggr:%+v query_string:%s", param, indexArr, tsAggr, string(b))
|
||||
|
||||
searchSource := elastic.NewSearchSource().
|
||||
Query(queryString).
|
||||
@@ -552,29 +551,29 @@ func QueryData(ctx context.Context, queryParam interface{}, cliTimeout int64, ve
|
||||
|
||||
searchSourceString, err := searchSource.Source()
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query_data searchSource:%s to string error:%v", searchSourceString, err)
|
||||
logger.Warningf("query_data searchSource:%s to string error:%v", searchSourceString, err)
|
||||
}
|
||||
|
||||
jsonSearchSource, err := json.Marshal(searchSourceString)
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query_data searchSource:%s to json error:%v", searchSourceString, err)
|
||||
logger.Warningf("query_data searchSource:%s to json error:%v", searchSourceString, err)
|
||||
}
|
||||
|
||||
result, err := search(ctx, indexArr, searchSource, param.Timeout, param.MaxShard)
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query_data searchSource:%s query_data error:%v", searchSourceString, err)
|
||||
logger.Warningf("query_data searchSource:%s query_data error:%v", searchSourceString, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查是否有 shard failures,有部分数据时仅记录警告继续处理
|
||||
if shardErr := checkShardFailures(ctx, result.Shards, "query_data", searchSourceString); shardErr != nil {
|
||||
if shardErr := checkShardFailures(result.Shards, "query_data", searchSourceString); shardErr != nil {
|
||||
if len(result.Aggregations["ts"]) == 0 {
|
||||
return nil, shardErr
|
||||
}
|
||||
// 有部分数据,checkShardFailures 已记录警告,继续处理
|
||||
}
|
||||
|
||||
logx.Infof(ctx, "query_data searchSource:%s resp:%s", string(jsonSearchSource), string(result.Aggregations["ts"]))
|
||||
logger.Debugf("query_data searchSource:%s resp:%s", string(jsonSearchSource), string(result.Aggregations["ts"]))
|
||||
|
||||
js, err := simplejson.NewJson(result.Aggregations["ts"])
|
||||
if err != nil {
|
||||
@@ -612,7 +611,7 @@ func QueryData(ctx context.Context, queryParam interface{}, cliTimeout int64, ve
|
||||
}
|
||||
|
||||
// checkShardFailures 检查 ES 查询结果中的 shard failures,返回格式化的错误信息
|
||||
func checkShardFailures(ctx context.Context, shards *elastic.ShardsInfo, logPrefix string, queryContext interface{}) error {
|
||||
func checkShardFailures(shards *elastic.ShardsInfo, logPrefix string, queryContext interface{}) error {
|
||||
if shards == nil || shards.Failed == 0 || len(shards.Failures) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -639,7 +638,7 @@ func checkShardFailures(ctx context.Context, shards *elastic.ShardsInfo, logPref
|
||||
|
||||
if len(failureReasons) > 0 {
|
||||
errMsg := fmt.Sprintf("elasticsearch shard failures (%d/%d failed): %s", shards.Failed, shards.Total, strings.Join(failureReasons, "; "))
|
||||
logx.Warningf(ctx, "%s query:%v %s", logPrefix, queryContext, errMsg)
|
||||
logger.Warningf("%s query:%v %s", logPrefix, queryContext, errMsg)
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return nil
|
||||
@@ -724,12 +723,12 @@ func QueryLog(ctx context.Context, queryParam interface{}, timeout int64, versio
|
||||
sourceBytes, _ := json.Marshal(source)
|
||||
result, err := search(ctx, indexArr, source, param.Timeout, param.MaxShard)
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query_log source:%s error:%v", string(sourceBytes), err)
|
||||
logger.Warningf("query_log source:%s error:%v", string(sourceBytes), err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 检查是否有 shard failures,有部分数据时仅记录警告继续处理
|
||||
if shardErr := checkShardFailures(ctx, result.Shards, "query_log", string(sourceBytes)); shardErr != nil {
|
||||
if shardErr := checkShardFailures(result.Shards, "query_log", string(sourceBytes)); shardErr != nil {
|
||||
if len(result.Hits.Hits) == 0 {
|
||||
return nil, 0, shardErr
|
||||
}
|
||||
@@ -738,17 +737,17 @@ func QueryLog(ctx context.Context, queryParam interface{}, timeout int64, versio
|
||||
|
||||
total := result.TotalHits()
|
||||
var ret []interface{}
|
||||
logx.Debugf(ctx, "query_log source:%s len:%d total:%d", string(sourceBytes), len(result.Hits.Hits), total)
|
||||
logger.Debugf("query_log source:%s len:%d total:%d", string(sourceBytes), len(result.Hits.Hits), total)
|
||||
|
||||
resultBytes, _ := json.Marshal(result)
|
||||
logx.Debugf(ctx, "query_log source:%s result:%s", string(sourceBytes), string(resultBytes))
|
||||
logger.Debugf("query_log source:%s result:%s", string(sourceBytes), string(resultBytes))
|
||||
|
||||
if strings.HasPrefix(version, "6") {
|
||||
for i := 0; i < len(result.Hits.Hits); i++ {
|
||||
var x map[string]interface{}
|
||||
err := json.Unmarshal(result.Hits.Hits[i].Source, &x)
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "Unmarshal source error:%v", err)
|
||||
logger.Warningf("Unmarshal source error:%v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -150,7 +148,7 @@ func (d *Doris) QueryData(ctx context.Context, query interface{}) ([]models.Data
|
||||
}
|
||||
}
|
||||
|
||||
items, err := d.QueryTimeseries(ctx, &doris.QueryParam{
|
||||
items, err := d.QueryTimeseries(context.TODO(), &doris.QueryParam{
|
||||
Database: dorisQueryParam.Database,
|
||||
Sql: dorisQueryParam.SQL,
|
||||
Keys: types.Keys{
|
||||
@@ -161,7 +159,7 @@ func (d *Doris) QueryData(ctx context.Context, query interface{}) ([]models.Data
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query:%+v get data err:%v", dorisQueryParam, err)
|
||||
logger.Warningf("query:%+v get data err:%v", dorisQueryParam, err)
|
||||
return []models.DataResp{}, err
|
||||
}
|
||||
data := make([]models.DataResp, 0)
|
||||
@@ -174,7 +172,7 @@ func (d *Doris) QueryData(ctx context.Context, query interface{}) ([]models.Data
|
||||
}
|
||||
|
||||
// parse resp to time series data
|
||||
logx.Infof(ctx, "req:%+v keys:%+v \n data:%v", dorisQueryParam, dorisQueryParam.Keys, data)
|
||||
logger.Infof("req:%+v keys:%+v \n data:%v", dorisQueryParam, dorisQueryParam.Keys, data)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -210,7 +208,7 @@ func (d *Doris) QueryLog(ctx context.Context, query interface{}) ([]interface{},
|
||||
Sql: dorisQueryParam.SQL,
|
||||
})
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query:%+v get data err:%v", dorisQueryParam, err)
|
||||
logger.Warningf("query:%+v get data err:%v", dorisQueryParam, err)
|
||||
return []interface{}{}, 0, err
|
||||
}
|
||||
logs := make([]interface{}, 0)
|
||||
|
||||
@@ -19,8 +19,7 @@ import (
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/olivere/elastic/v7"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -381,14 +380,14 @@ func (e *Elasticsearch) QueryMapData(ctx context.Context, query interface{}) ([]
|
||||
|
||||
var result []map[string]string
|
||||
for _, item := range res {
|
||||
logx.Debugf(ctx, "query:%v item:%v", query, item)
|
||||
logger.Debugf("query:%v item:%v", query, item)
|
||||
if itemMap, ok := item.(*elastic.SearchHit); ok {
|
||||
mItem := make(map[string]string)
|
||||
// 遍历 fields 字段的每个键值对
|
||||
sourceMap := make(map[string]interface{})
|
||||
err := json.Unmarshal(itemMap.Source, &sourceMap)
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "unmarshal source%s error:%v", string(itemMap.Source), err)
|
||||
logger.Warningf("unmarshal source%s error:%v", string(itemMap.Source), err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -167,7 +165,7 @@ func (m *MySQL) QueryData(ctx context.Context, query interface{}) ([]models.Data
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query:%+v get data err:%v", mysqlQueryParam, err)
|
||||
logger.Warningf("query:%+v get data err:%v", mysqlQueryParam, err)
|
||||
return []models.DataResp{}, err
|
||||
}
|
||||
data := make([]models.DataResp, 0)
|
||||
@@ -209,7 +207,7 @@ func (m *MySQL) QueryLog(ctx context.Context, query interface{}) ([]interface{},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query:%+v get data err:%v", mysqlQueryParam, err)
|
||||
logger.Warningf("query:%+v get data err:%v", mysqlQueryParam, err)
|
||||
return []interface{}{}, 0, err
|
||||
}
|
||||
logs := make([]interface{}, 0)
|
||||
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -199,7 +197,7 @@ func (p *PostgreSQL) QueryData(ctx context.Context, query interface{}) ([]models
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query:%+v get data err:%v", postgresqlQueryParam, err)
|
||||
logger.Warningf("query:%+v get data err:%v", postgresqlQueryParam, err)
|
||||
return []models.DataResp{}, err
|
||||
}
|
||||
data := make([]models.DataResp, 0)
|
||||
@@ -212,7 +210,7 @@ func (p *PostgreSQL) QueryData(ctx context.Context, query interface{}) ([]models
|
||||
}
|
||||
|
||||
// parse resp to time series data
|
||||
logx.Infof(ctx, "req:%+v keys:%+v \n data:%v", postgresqlQueryParam, postgresqlQueryParam.Keys, data)
|
||||
logger.Infof("req:%+v keys:%+v \n data:%v", postgresqlQueryParam, postgresqlQueryParam.Keys, data)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -251,7 +249,7 @@ func (p *PostgreSQL) QueryLog(ctx context.Context, query interface{}) ([]interfa
|
||||
Sql: postgresqlQueryParam.SQL,
|
||||
})
|
||||
if err != nil {
|
||||
logx.Warningf(ctx, "query:%+v get data err:%v", postgresqlQueryParam, err)
|
||||
logger.Warningf("query:%+v get data err:%v", postgresqlQueryParam, err)
|
||||
return []interface{}{}, 0, err
|
||||
}
|
||||
logs := make([]interface{}, 0)
|
||||
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/toolkits/pkg/logger"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/pkg/logx"
|
||||
|
||||
"github.com/ccfos/nightingale/v6/datasource"
|
||||
td "github.com/ccfos/nightingale/v6/dskit/tdengine"
|
||||
"github.com/ccfos/nightingale/v6/models"
|
||||
@@ -120,7 +118,7 @@ func (td *TDengine) MakeTSQuery(ctx context.Context, query interface{}, eventTag
|
||||
}
|
||||
|
||||
func (td *TDengine) QueryData(ctx context.Context, queryParam interface{}) ([]models.DataResp, error) {
|
||||
return td.Query(ctx, queryParam, 0)
|
||||
return td.Query(queryParam, 0)
|
||||
}
|
||||
|
||||
func (td *TDengine) QueryLog(ctx context.Context, queryParam interface{}) ([]interface{}, int64, error) {
|
||||
@@ -172,7 +170,7 @@ func (td *TDengine) QueryMapData(ctx context.Context, query interface{}) ([]map[
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (td *TDengine) Query(ctx context.Context, query interface{}, delay ...int) ([]models.DataResp, error) {
|
||||
func (td *TDengine) Query(query interface{}, delay ...int) ([]models.DataResp, error) {
|
||||
b, err := json.Marshal(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -214,7 +212,7 @@ func (td *TDengine) Query(ctx context.Context, query interface{}, delay ...int)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logx.Debugf(ctx, "tdengine query:%s result: %+v", q.Query, data)
|
||||
logger.Debugf("tdengine query:%s result: %+v", q.Query, data)
|
||||
|
||||
return ConvertToTStData(data, q.Keys, q.Ref)
|
||||
}
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
# AI Agent API
|
||||
|
||||
所有接口需要管理员权限(`auth` + `admin`)。
|
||||
|
||||
## 数据结构
|
||||
|
||||
### AIAgent
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | int64 | - | 主键,自增 |
|
||||
| name | string | 是 | Agent 名称 |
|
||||
| description | string | 否 | 描述 |
|
||||
| use_case | string | 否 | 用途场景,如 `chat` |
|
||||
| llm_config_id | int64 | 是 | 关联的 LLM 配置 ID |
|
||||
| skill_ids | int64[] | 否 | 关联的 Skill ID 列表 |
|
||||
| mcp_server_ids | int64[] | 否 | 关联的 MCP Server ID 列表 |
|
||||
| enabled | int | 否 | 是否启用,默认 1 |
|
||||
| created_at | int64 | - | 创建时间(Unix 时间戳) |
|
||||
| created_by | string | - | 创建人 |
|
||||
| updated_at | int64 | - | 更新时间(Unix 时间戳) |
|
||||
| updated_by | string | - | 更新人 |
|
||||
| llm_config | object | - | 运行时字段,关联的 LLM 配置对象(不存储) |
|
||||
|
||||
---
|
||||
|
||||
## 获取 Agent 列表
|
||||
|
||||
```
|
||||
GET /api/n9e/ai-agents
|
||||
```
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "chat-agent",
|
||||
"description": "AI 对话 Agent",
|
||||
"use_case": "chat",
|
||||
"llm_config_id": 1,
|
||||
"skill_ids": [1, 2],
|
||||
"mcp_server_ids": [1],
|
||||
"enabled": 1,
|
||||
"created_at": 1710000000,
|
||||
"created_by": "admin",
|
||||
"updated_at": 1710000000,
|
||||
"updated_by": "admin"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 获取 Agent 详情
|
||||
|
||||
```
|
||||
GET /api/n9e/ai-agent/:id
|
||||
```
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | Agent ID |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": {
|
||||
"id": 1,
|
||||
"name": "chat-agent",
|
||||
"description": "AI 对话 Agent",
|
||||
"use_case": "chat",
|
||||
"llm_config_id": 1,
|
||||
"skill_ids": [1, 2],
|
||||
"mcp_server_ids": [1],
|
||||
"enabled": 1,
|
||||
"created_at": 1710000000,
|
||||
"created_by": "admin",
|
||||
"updated_at": 1710000000,
|
||||
"updated_by": "admin"
|
||||
},
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
|
||||
- `404` Agent 不存在
|
||||
|
||||
---
|
||||
|
||||
## 创建 Agent
|
||||
|
||||
```
|
||||
POST /api/n9e/ai-agents
|
||||
```
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "chat-agent",
|
||||
"description": "AI 对话 Agent",
|
||||
"use_case": "chat",
|
||||
"llm_config_id": 1,
|
||||
"skill_ids": [1, 2],
|
||||
"mcp_server_ids": [1],
|
||||
"enabled": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 校验规则
|
||||
|
||||
- `name` 必填
|
||||
- `llm_config_id` 必填,且大于 0
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": 1,
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
返回新创建的 Agent ID。
|
||||
|
||||
---
|
||||
|
||||
## 更新 Agent
|
||||
|
||||
```
|
||||
PUT /api/n9e/ai-agent/:id
|
||||
```
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | Agent ID |
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "chat-agent-v2",
|
||||
"description": "更新后的描述",
|
||||
"use_case": "chat",
|
||||
"llm_config_id": 2,
|
||||
"skill_ids": [1, 3],
|
||||
"mcp_server_ids": [],
|
||||
"enabled": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 校验规则
|
||||
|
||||
同创建接口。
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": "",
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
|
||||
- `404` Agent 不存在
|
||||
|
||||
---
|
||||
|
||||
## 删除 Agent
|
||||
|
||||
```
|
||||
DELETE /api/n9e/ai-agent/:id
|
||||
```
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | Agent ID |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": "",
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
|
||||
- `404` Agent 不存在
|
||||
|
||||
---
|
||||
|
||||
## 测试 Agent
|
||||
|
||||
通过 Agent 关联的 LLM 配置发送测试请求,验证连通性。
|
||||
|
||||
```
|
||||
POST /api/n9e/ai-agent/:id/test
|
||||
```
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | Agent ID |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": {
|
||||
"success": true,
|
||||
"duration_ms": 1234
|
||||
},
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
失败时:
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": {
|
||||
"success": false,
|
||||
"duration_ms": 5000,
|
||||
"error": "HTTP 401: Unauthorized"
|
||||
},
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
|
||||
- `404` Agent 不存在
|
||||
- `400` 关联的 LLM 配置不存在
|
||||
@@ -1,290 +0,0 @@
|
||||
# AI LLM Config API
|
||||
|
||||
所有接口需要管理员权限(`auth` + `admin`)。
|
||||
|
||||
## 数据结构
|
||||
|
||||
### AILLMConfig
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | int64 | - | 主键,自增 |
|
||||
| name | string | 是 | 配置名称 |
|
||||
| description | string | 否 | 描述 |
|
||||
| api_type | string | 是 | 提供商类型:`openai`、`claude`、`gemini` |
|
||||
| api_url | string | 是 | API 地址 |
|
||||
| api_key | string | 是 | API 密钥 |
|
||||
| model | string | 是 | 模型名称 |
|
||||
| extra_config | object | 否 | 高级配置,见 LLMExtraConfig |
|
||||
| enabled | int | 否 | 是否启用,默认 1 |
|
||||
| created_at | int64 | - | 创建时间(Unix 时间戳) |
|
||||
| created_by | string | - | 创建人 |
|
||||
| updated_at | int64 | - | 更新时间(Unix 时间戳) |
|
||||
| updated_by | string | - | 更新人 |
|
||||
|
||||
### LLMExtraConfig
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| timeout_seconds | int | 请求超时时间(秒),默认 30 |
|
||||
| skip_tls_verify | bool | 跳过 TLS 证书校验 |
|
||||
| proxy | string | HTTP 代理地址 |
|
||||
| custom_headers | map[string]string | 自定义请求头 |
|
||||
| custom_params | map[string]any | 自定义请求参数 |
|
||||
| temperature | float64 | 生成温度(可选) |
|
||||
| max_tokens | int | 最大输出 Token 数(可选) |
|
||||
| context_length | int | 上下文窗口大小(可选) |
|
||||
|
||||
---
|
||||
|
||||
## 获取 LLM 配置列表
|
||||
|
||||
```
|
||||
GET /api/n9e/ai-llm-configs
|
||||
```
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "gpt-4o",
|
||||
"description": "OpenAI GPT-4o",
|
||||
"api_type": "openai",
|
||||
"api_url": "https://api.openai.com",
|
||||
"api_key": "sk-xxx",
|
||||
"model": "gpt-4o",
|
||||
"extra_config": {
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096
|
||||
},
|
||||
"enabled": 1,
|
||||
"created_at": 1710000000,
|
||||
"created_by": "admin",
|
||||
"updated_at": 1710000000,
|
||||
"updated_by": "admin"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 获取 LLM 配置详情
|
||||
|
||||
```
|
||||
GET /api/n9e/ai-llm-config/:id
|
||||
```
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | LLM 配置 ID |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": {
|
||||
"id": 1,
|
||||
"name": "gpt-4o",
|
||||
"description": "OpenAI GPT-4o",
|
||||
"api_type": "openai",
|
||||
"api_url": "https://api.openai.com",
|
||||
"api_key": "sk-xxx",
|
||||
"model": "gpt-4o",
|
||||
"extra_config": {
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096
|
||||
},
|
||||
"enabled": 1,
|
||||
"created_at": 1710000000,
|
||||
"created_by": "admin",
|
||||
"updated_at": 1710000000,
|
||||
"updated_by": "admin"
|
||||
},
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
|
||||
- `404` LLM 配置不存在
|
||||
|
||||
---
|
||||
|
||||
## 创建 LLM 配置
|
||||
|
||||
```
|
||||
POST /api/n9e/ai-llm-configs
|
||||
```
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "gpt-4o",
|
||||
"description": "OpenAI GPT-4o",
|
||||
"api_type": "openai",
|
||||
"api_url": "https://api.openai.com",
|
||||
"api_key": "sk-xxx",
|
||||
"model": "gpt-4o",
|
||||
"extra_config": {
|
||||
"timeout_seconds": 60,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
"custom_headers": {
|
||||
"X-Custom": "value"
|
||||
}
|
||||
},
|
||||
"enabled": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 校验规则
|
||||
|
||||
- `name`、`api_type`、`api_url`、`api_key`、`model` 均为必填
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": 1,
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
返回新创建的配置 ID。
|
||||
|
||||
---
|
||||
|
||||
## 更新 LLM 配置
|
||||
|
||||
```
|
||||
PUT /api/n9e/ai-llm-config/:id
|
||||
```
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | LLM 配置 ID |
|
||||
|
||||
### 请求体
|
||||
|
||||
同创建接口。**注意:如果 `api_key` 为空,则保留原值不更新。**
|
||||
|
||||
### 校验规则
|
||||
|
||||
同创建接口。
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": "",
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
|
||||
- `404` LLM 配置不存在
|
||||
|
||||
---
|
||||
|
||||
## 删除 LLM 配置
|
||||
|
||||
```
|
||||
DELETE /api/n9e/ai-llm-config/:id
|
||||
```
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | LLM 配置 ID |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": "",
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
|
||||
- `404` LLM 配置不存在
|
||||
|
||||
---
|
||||
|
||||
## 测试 LLM 连接
|
||||
|
||||
无需先创建配置,直接传入连接参数进行连通性测试。
|
||||
|
||||
```
|
||||
POST /api/n9e/ai-llm-config/test
|
||||
```
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"api_type": "openai",
|
||||
"api_url": "https://api.openai.com",
|
||||
"api_key": "sk-xxx",
|
||||
"model": "gpt-4o",
|
||||
"extra_config": {
|
||||
"timeout_seconds": 30,
|
||||
"skip_tls_verify": false,
|
||||
"proxy": "",
|
||||
"custom_headers": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 校验规则
|
||||
|
||||
- `api_type`、`api_url`、`api_key`、`model` 均为必填
|
||||
|
||||
### 测试行为
|
||||
|
||||
根据 `api_type` 向对应的 API 发送一个最小请求("Hi",max_tokens=5):
|
||||
|
||||
| api_type | 请求地址 | 认证方式 |
|
||||
|----------|---------|---------|
|
||||
| openai | `{api_url}/chat/completions` | `Authorization: Bearer {api_key}` |
|
||||
| claude | `{api_url}/v1/messages` | `x-api-key: {api_key}` |
|
||||
| gemini | `{api_url}/v1beta/models/{model}:generateContent?key={api_key}` | URL 参数 |
|
||||
|
||||
### 响应
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": {
|
||||
"success": true,
|
||||
"duration_ms": 856
|
||||
},
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
失败:
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": {
|
||||
"success": false,
|
||||
"duration_ms": 5000
|
||||
},
|
||||
"err": "HTTP 401: {\"error\": \"invalid api key\"}"
|
||||
}
|
||||
```
|
||||
@@ -1,292 +0,0 @@
|
||||
# MCP Server API
|
||||
|
||||
所有接口需要管理员权限(`auth` + `admin`)。
|
||||
|
||||
## 数据结构
|
||||
|
||||
### MCPServer
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | int64 | - | 主键,自增 |
|
||||
| name | string | 是 | 名称 |
|
||||
| url | string | 是 | MCP Server 地址 |
|
||||
| headers | map[string]string | 否 | 自定义 HTTP 请求头,用于认证等 |
|
||||
| description | string | 否 | 描述 |
|
||||
| enabled | int | 否 | 是否启用,默认 1 |
|
||||
| created_at | int64 | - | 创建时间(Unix 时间戳) |
|
||||
| created_by | string | - | 创建人 |
|
||||
| updated_at | int64 | - | 更新时间(Unix 时间戳) |
|
||||
| updated_by | string | - | 更新人 |
|
||||
|
||||
---
|
||||
|
||||
## 获取 MCP Server 列表
|
||||
|
||||
```
|
||||
GET /api/n9e/mcp-servers
|
||||
```
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "my-mcp-server",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer xxx"
|
||||
},
|
||||
"description": "示例 MCP Server",
|
||||
"enabled": 1,
|
||||
"created_at": 1710000000,
|
||||
"created_by": "admin",
|
||||
"updated_at": 1710000000,
|
||||
"updated_by": "admin"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 获取 MCP Server 详情
|
||||
|
||||
```
|
||||
GET /api/n9e/mcp-server/:id
|
||||
```
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | MCP Server ID |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": {
|
||||
"id": 1,
|
||||
"name": "my-mcp-server",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer xxx"
|
||||
},
|
||||
"description": "示例 MCP Server",
|
||||
"enabled": 1,
|
||||
"created_at": 1710000000,
|
||||
"created_by": "admin",
|
||||
"updated_at": 1710000000,
|
||||
"updated_by": "admin"
|
||||
},
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
|
||||
- `404` MCP Server 不存在
|
||||
|
||||
---
|
||||
|
||||
## 创建 MCP Server
|
||||
|
||||
```
|
||||
POST /api/n9e/mcp-servers
|
||||
```
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-mcp-server",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer xxx"
|
||||
},
|
||||
"description": "示例 MCP Server",
|
||||
"enabled": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 校验规则
|
||||
|
||||
- `name` 必填(自动 trim)
|
||||
- `url` 必填(自动 trim)
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": 1,
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
返回新创建的 MCP Server ID。
|
||||
|
||||
---
|
||||
|
||||
## 更新 MCP Server
|
||||
|
||||
```
|
||||
PUT /api/n9e/mcp-server/:id
|
||||
```
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | MCP Server ID |
|
||||
|
||||
### 请求体
|
||||
|
||||
同创建接口。
|
||||
|
||||
### 可更新字段
|
||||
|
||||
`name`、`url`、`headers`、`description`、`enabled`。
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": "",
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
|
||||
- `404` MCP Server 不存在
|
||||
|
||||
---
|
||||
|
||||
## 删除 MCP Server
|
||||
|
||||
```
|
||||
DELETE /api/n9e/mcp-server/:id
|
||||
```
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | MCP Server ID |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": "",
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
|
||||
- `404` MCP Server 不存在
|
||||
|
||||
---
|
||||
|
||||
## 测试 MCP Server 连接
|
||||
|
||||
无需先创建,直接传入连接参数进行连通性测试。通过 MCP 协议初始化握手并获取工具列表来验证连通性。
|
||||
|
||||
```
|
||||
POST /api/n9e/mcp-server/test
|
||||
```
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 校验规则
|
||||
|
||||
- `url` 必填
|
||||
|
||||
### 测试行为
|
||||
|
||||
1. 发送 `initialize` 请求(协议版本 `2024-11-05`)
|
||||
2. 发送 `notifications/initialized` 通知
|
||||
3. 发送 `tools/list` 请求获取工具列表
|
||||
|
||||
支持 JSON 和 SSE(`text/event-stream`)两种响应格式。
|
||||
|
||||
### 响应
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": {
|
||||
"success": true,
|
||||
"duration_ms": 320,
|
||||
"tool_count": 5
|
||||
},
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
失败:
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": {
|
||||
"success": false,
|
||||
"duration_ms": 5000,
|
||||
"tool_count": 0,
|
||||
"error": "initialize: HTTP 403: Forbidden"
|
||||
},
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 获取 MCP Server 工具列表
|
||||
|
||||
获取已创建的 MCP Server 提供的工具列表。
|
||||
|
||||
```
|
||||
GET /api/n9e/mcp-server/:id/tools
|
||||
```
|
||||
|
||||
### 路径参数
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | MCP Server ID |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"dat": [
|
||||
{
|
||||
"name": "query_database",
|
||||
"description": "Execute a SQL query against the database"
|
||||
},
|
||||
{
|
||||
"name": "search_logs",
|
||||
"description": "Search through application logs"
|
||||
}
|
||||
],
|
||||
"err": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
|
||||
- `404` MCP Server 不存在
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user