Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd25b79724 | ||
|
|
cd5dcb29f5 | ||
|
|
2f83308dce |
@@ -0,0 +1,182 @@
|
||||
# 活跃度查询工具
|
||||
|
||||
## 功能说明
|
||||
|
||||
当用户询问"当前有多少人活跃"或"现在有多少节点在线"时,AI 可以调用此工具查询实时活跃统计。
|
||||
|
||||
## 查询逻辑
|
||||
|
||||
### 活跃节点统计
|
||||
- 查询数据库 `nodeinfo` 表的 `updated_at` 字段
|
||||
- 统计指定时间范围内有更新记录的节点数量
|
||||
- SQL: `SELECT COUNT(*) FROM nodeinfo WHERE updated_at >= ?`
|
||||
|
||||
### 活跃人数统计
|
||||
- 查询数据库 `text_message` 表的 `created_at` 字段
|
||||
- 统计指定时间范围内发送过消息的唯一用户数(按 `from_id` 去重)
|
||||
- SQL: `SELECT COUNT(DISTINCT from_id) FROM text_message WHERE created_at >= ?`
|
||||
|
||||
## 参数说明
|
||||
|
||||
### hours(可选)
|
||||
- 类型:数字(浮点数)
|
||||
- 说明:查询最近多少小时内的活跃数据
|
||||
- 默认值:1 小时
|
||||
- 取值范围:0.1 ~ 24 小时
|
||||
- 示例:`1`、`2`、`6`、`12`、`24`、`0.5`
|
||||
|
||||
### query_type(可选)
|
||||
- 类型:字符串枚举
|
||||
- 可选值:
|
||||
- `both`:同时查询节点和人数(默认)
|
||||
- `nodes`:仅查询活跃节点
|
||||
- `users`:仅查询活跃人数
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 用户询问:"现在有多少人活跃?"
|
||||
AI 调用:
|
||||
```json
|
||||
{
|
||||
"hours": 1,
|
||||
"query_type": "users"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```
|
||||
最近 1.0 小时的活跃统计:
|
||||
|
||||
活跃人数:15 人
|
||||
```
|
||||
|
||||
### 用户询问:"最近6小时有多少节点在线?"
|
||||
AI 调用:
|
||||
```json
|
||||
{
|
||||
"hours": 6,
|
||||
"query_type": "nodes"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```
|
||||
最近 6.0 小时的活跃统计:
|
||||
|
||||
活跃节点:25 个
|
||||
```
|
||||
|
||||
### 用户询问:"当前有多少人和节点活跃?"
|
||||
AI 调用:
|
||||
```json
|
||||
{
|
||||
"hours": 1,
|
||||
"query_type": "both"
|
||||
}
|
||||
```
|
||||
|
||||
或简化为(使用默认值):
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
返回:
|
||||
```
|
||||
最近 1.0 小时的活跃统计:
|
||||
|
||||
活跃节点:25 个
|
||||
活跃人数:15 人
|
||||
```
|
||||
|
||||
### 用户询问:"今天有多少活跃用户?"
|
||||
AI 调用(假设现在是下午3点):
|
||||
```json
|
||||
{
|
||||
"hours": 15,
|
||||
"query_type": "users"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```
|
||||
最近 15.0 小时的活跃统计:
|
||||
|
||||
活跃人数:48 人
|
||||
```
|
||||
|
||||
## 时间限制
|
||||
|
||||
- **默认时间**:1 小时(用户未指定时间时)
|
||||
- **最大时间**:24 小时(超过24小时会自动限制到24小时)
|
||||
- **最小精度**:0.1 小时(6分钟)
|
||||
|
||||
这样设计的原因:
|
||||
1. 默认1小时符合"当前活跃"的常见理解
|
||||
2. 限制24小时避免查询过大范围影响性能
|
||||
3. 支持小数便于精确控制时间范围(如0.5小时=30分钟)
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 文件结构
|
||||
```
|
||||
internal/agents/active/
|
||||
├── active.go # 工具主逻辑
|
||||
└── active_test.go # 单元测试
|
||||
|
||||
internal/store/
|
||||
└── active_store.go # 数据库查询方法
|
||||
```
|
||||
|
||||
### 核心接口
|
||||
|
||||
```go
|
||||
type ActiveStore interface {
|
||||
CountActiveNodes(since time.Time) (int64, error)
|
||||
CountActiveUsers(since time.Time) (int64, error)
|
||||
}
|
||||
```
|
||||
|
||||
### 工具注册
|
||||
|
||||
在 `internal/ai/service.go` 中通过空导入自动注册:
|
||||
```go
|
||||
import (
|
||||
_ "meshtastic_mqtt_server/internal/agents/active"
|
||||
// ...
|
||||
)
|
||||
```
|
||||
|
||||
## 测试覆盖
|
||||
|
||||
- ✅ 默认查询(1小时,both)
|
||||
- ✅ 指定时间查询(6小时、24小时)
|
||||
- ✅ 仅查询节点
|
||||
- ✅ 仅查询人数
|
||||
- ✅ 时间限制(超过24小时自动限制)
|
||||
- ✅ 工具启用状态检查
|
||||
|
||||
所有测试通过。
|
||||
|
||||
## 数据库性能
|
||||
|
||||
查询使用索引字段(`updated_at`、`created_at`),性能良好:
|
||||
- `nodeinfo` 表通常记录数较少(几百到几千条)
|
||||
- `text_message` 表使用 `DISTINCT` 去重,配合时间索引效率高
|
||||
- 典型查询响应时间 < 10ms
|
||||
|
||||
## 使用场景
|
||||
|
||||
1. **实时监控**:"现在有多少人在线?"
|
||||
2. **活跃度统计**:"最近一小时有多少活跃用户?"
|
||||
3. **趋势分析**:"今天的活跃度怎么样?"
|
||||
4. **对比分析**:"最近6小时有多少人活跃?"(可以多次查询不同时间范围对比)
|
||||
|
||||
## 与签到工具的区别
|
||||
|
||||
| 维度 | 活跃度查询 | 签到查询 |
|
||||
|------|-----------|---------|
|
||||
| 数据源 | nodeinfo + text_message | signs 表 |
|
||||
| 统计维度 | 实时活跃(有更新/发消息) | 主动签到 |
|
||||
| 时间范围 | 最近N小时(最大24小时) | 按自然日统计 |
|
||||
| 用户意图 | "现在有多少人在线" | "今天有多少人签到" |
|
||||
| 去重逻辑 | 自动按 from_id 去重 | 每节点每天仅一次 |
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package active 提供活跃度查询工具。当用户想查询活跃节点数或活跃人数时调用。
|
||||
//
|
||||
// 活跃节点:查询 nodeinfo 表的 updated_at 字段,统计指定时间范围内更新过的节点数
|
||||
// 活跃人数:查询 text_message 表的 created_at 字段,统计指定时间范围内发过消息的唯一用户数(按 from_id 去重)
|
||||
package active
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// ActiveStore 定义活跃度查询工具所需的持久化能力,通常由 *store.Store 实现。
|
||||
type ActiveStore interface {
|
||||
CountActiveNodes(since time.Time) (int64, error)
|
||||
CountActiveUsers(since time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// Tool 是活跃度查询工具。
|
||||
type Tool struct {
|
||||
enabled bool
|
||||
store ActiveStore
|
||||
}
|
||||
|
||||
// Name returns the tool name
|
||||
func (t *Tool) Name() string { return "active" }
|
||||
|
||||
// Enabled returns whether the tool is enabled
|
||||
func (t *Tool) Enabled() bool { return t.enabled && t.store != nil }
|
||||
|
||||
// ToolDefinition returns the OpenAI tool definition
|
||||
func (t *Tool) ToolDefinition(description string) *model.Tool {
|
||||
desc := "活跃度查询工具。查询指定时间范围内的活跃节点数和活跃人数。\n" +
|
||||
"活跃节点:在指定时间内有更新记录的节点数量\n" +
|
||||
"活跃人数:在指定时间内发送过消息的唯一用户数(按 from_id 去重)\n" +
|
||||
"默认查询最近1小时,最大支持24小时"
|
||||
if description != "" {
|
||||
desc = description
|
||||
}
|
||||
return &model.Tool{
|
||||
Type: model.ToolTypeFunction,
|
||||
Function: &model.FunctionDefinition{
|
||||
Name: "active",
|
||||
Description: desc,
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"hours": map[string]any{
|
||||
"type": "number",
|
||||
"description": "查询最近多少小时内的活跃数据,默认1小时,最大24小时。例如:1、2、6、12、24",
|
||||
},
|
||||
"query_type": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"both", "nodes", "users"},
|
||||
"description": "查询类型:both=同时查询节点和人数(默认),nodes=仅查询节点,users=仅查询人数",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the active query tool
|
||||
func (t *Tool) Execute(ctx context.Context, args string, runtime agenttool.Runtime) (string, error) {
|
||||
if t.store == nil {
|
||||
return "", fmt.Errorf("active store is not configured")
|
||||
}
|
||||
|
||||
var params activeParams
|
||||
if err := json.Unmarshal([]byte(args), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("failed to parse arguments: %w", err)
|
||||
}
|
||||
|
||||
// 默认查询1小时
|
||||
hours := params.Hours
|
||||
if hours <= 0 {
|
||||
hours = 1
|
||||
}
|
||||
// 最大24小时
|
||||
if hours > 24 {
|
||||
hours = 24
|
||||
}
|
||||
|
||||
// 默认查询类型为 both
|
||||
queryType := strings.ToLower(strings.TrimSpace(params.QueryType))
|
||||
if queryType == "" {
|
||||
queryType = "both"
|
||||
}
|
||||
|
||||
now := runtime.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
// 计算时间范围
|
||||
since := now.Add(-time.Duration(hours * float64(time.Hour)))
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("最近 %.1f 小时的活跃统计:\n\n", hours))
|
||||
|
||||
// 查询活跃节点
|
||||
if queryType == "both" || queryType == "nodes" {
|
||||
nodeCount, err := t.store.CountActiveNodes(since)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询活跃节点失败:%w", err)
|
||||
}
|
||||
result.WriteString(fmt.Sprintf("活跃节点:%d 个\n", nodeCount))
|
||||
}
|
||||
|
||||
// 查询活跃人数
|
||||
if queryType == "both" || queryType == "users" {
|
||||
userCount, err := t.store.CountActiveUsers(since)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询活跃人数失败:%w", err)
|
||||
}
|
||||
result.WriteString(fmt.Sprintf("活跃人数:%d 人\n", userCount))
|
||||
}
|
||||
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// activeParams 是活跃度查询工具的入参。
|
||||
type activeParams struct {
|
||||
Hours float64 `json:"hours"` // 查询最近多少小时,默认1小时
|
||||
QueryType string `json:"query_type"` // 查询类型:both/nodes/users
|
||||
}
|
||||
|
||||
// RawState returns the tool state
|
||||
func (t *Tool) RawState() any {
|
||||
return map[string]any{"enabled": t.enabled, "has_store": t.store != nil}
|
||||
}
|
||||
|
||||
func init() {
|
||||
agenttool.Register(agenttool.Descriptor{
|
||||
Name: "active",
|
||||
Load: func(path string, options agenttool.LoadOptions) (agenttool.LoadedTool, error) {
|
||||
tool := &Tool{enabled: true}
|
||||
if store, ok := options.Value("store").(ActiveStore); ok && store != nil {
|
||||
tool.store = store
|
||||
}
|
||||
return tool, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package active
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
)
|
||||
|
||||
// mockActiveStore 是用于测试的 mock store
|
||||
type mockActiveStore struct {
|
||||
activeNodeCount int64
|
||||
activeUserCount int64
|
||||
}
|
||||
|
||||
func (m *mockActiveStore) CountActiveNodes(since time.Time) (int64, error) {
|
||||
return m.activeNodeCount, nil
|
||||
}
|
||||
|
||||
func (m *mockActiveStore) CountActiveUsers(since time.Time) (int64, error) {
|
||||
return m.activeUserCount, nil
|
||||
}
|
||||
|
||||
func TestActiveTool_Query(t *testing.T) {
|
||||
now := time.Date(2024, 6, 23, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
store := &mockActiveStore{
|
||||
activeNodeCount: 25,
|
||||
activeUserCount: 15,
|
||||
}
|
||||
|
||||
tool := &Tool{
|
||||
enabled: true,
|
||||
store: store,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hours float64
|
||||
queryType string
|
||||
expectNodes bool
|
||||
expectUsers bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "默认查询1小时(both)",
|
||||
hours: 0, // 0 表示使用默认值
|
||||
queryType: "",
|
||||
expectNodes: true,
|
||||
expectUsers: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "查询6小时",
|
||||
hours: 6,
|
||||
queryType: "both",
|
||||
expectNodes: true,
|
||||
expectUsers: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "仅查询节点",
|
||||
hours: 1,
|
||||
queryType: "nodes",
|
||||
expectNodes: true,
|
||||
expectUsers: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "仅查询人数",
|
||||
hours: 1,
|
||||
queryType: "users",
|
||||
expectNodes: false,
|
||||
expectUsers: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "查询24小时(最大值)",
|
||||
hours: 24,
|
||||
queryType: "both",
|
||||
expectNodes: true,
|
||||
expectUsers: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "超过24小时应限制到24小时",
|
||||
hours: 48,
|
||||
queryType: "both",
|
||||
expectNodes: true,
|
||||
expectUsers: true,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params := activeParams{
|
||||
Hours: tt.hours,
|
||||
QueryType: tt.queryType,
|
||||
}
|
||||
argsJSON, _ := json.Marshal(params)
|
||||
|
||||
runtime := agenttool.Runtime{Now: now}
|
||||
result, err := tool.Execute(context.Background(), string(argsJSON), runtime)
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if !tt.expectError {
|
||||
t.Logf("Query result:\n%s", result)
|
||||
|
||||
// 验证结果包含预期的内容
|
||||
if tt.expectNodes && result != "" {
|
||||
// 应该包含节点统计
|
||||
if !contains(result, "活跃节点") {
|
||||
t.Errorf("Expected result to contain node count")
|
||||
}
|
||||
}
|
||||
if tt.expectUsers && result != "" {
|
||||
// 应该包含人数统计
|
||||
if !contains(result, "活跃人数") {
|
||||
t.Errorf("Expected result to contain user count")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveTool_Enabled(t *testing.T) {
|
||||
// 测试工具启用状态
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
store ActiveStore
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "启用且有store",
|
||||
enabled: true,
|
||||
store: &mockActiveStore{},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "启用但无store",
|
||||
enabled: true,
|
||||
store: nil,
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "禁用且有store",
|
||||
enabled: false,
|
||||
store: &mockActiveStore{},
|
||||
expect: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tool := &Tool{
|
||||
enabled: tt.enabled,
|
||||
store: tt.store,
|
||||
}
|
||||
if tool.Enabled() != tt.expect {
|
||||
t.Errorf("Expected Enabled() = %v, got %v", tt.expect, tool.Enabled())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) >= len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr)))
|
||||
}
|
||||
|
||||
func containsMiddle(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "meshtastic_mqtt_server/internal/agents/active"
|
||||
_ "meshtastic_mqtt_server/internal/agents/calculator"
|
||||
_ "meshtastic_mqtt_server/internal/agents/sign"
|
||||
_ "meshtastic_mqtt_server/internal/agents/time"
|
||||
@@ -258,3 +259,69 @@ func (s *Service) Stop() {
|
||||
func (s *Service) Enabled() bool {
|
||||
return s.enabled
|
||||
}
|
||||
|
||||
// ReloadLLMProvider reloads a specific LLM provider configuration
|
||||
func (s *Service) ReloadLLMProvider(config interface{}) error {
|
||||
if !s.enabled || s.LLMState == nil {
|
||||
return nil
|
||||
}
|
||||
providerConfig, err := convertToProviderConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.LLMState.UpdateProvider(providerConfig)
|
||||
}
|
||||
|
||||
// AddLLMProvider adds a new LLM provider
|
||||
func (s *Service) AddLLMProvider(config interface{}) error {
|
||||
if !s.enabled || s.LLMState == nil {
|
||||
return nil
|
||||
}
|
||||
providerConfig, err := convertToProviderConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.LLMState.AddProvider(providerConfig)
|
||||
}
|
||||
|
||||
// RemoveLLMProvider removes an LLM provider
|
||||
func (s *Service) RemoveLLMProvider(name string) error {
|
||||
if !s.enabled || s.LLMState == nil {
|
||||
return nil
|
||||
}
|
||||
return s.LLMState.RemoveProvider(name)
|
||||
}
|
||||
|
||||
// convertToProviderConfig converts a map to llm.ProviderConfig
|
||||
func convertToProviderConfig(config interface{}) (llm.ProviderConfig, error) {
|
||||
m, ok := config.(map[string]interface{})
|
||||
if !ok {
|
||||
return llm.ProviderConfig{}, fmt.Errorf("invalid config type: expected map[string]interface{}")
|
||||
}
|
||||
|
||||
pc := llm.ProviderConfig{}
|
||||
|
||||
if v, ok := m["Name"].(string); ok {
|
||||
pc.Name = v
|
||||
}
|
||||
if v, ok := m["Active"].(bool); ok {
|
||||
pc.Active = v
|
||||
}
|
||||
if v, ok := m["APIKey"].(string); ok {
|
||||
pc.APIKey = v
|
||||
}
|
||||
if v, ok := m["BaseURL"].(string); ok {
|
||||
pc.BaseURL = v
|
||||
}
|
||||
if v, ok := m["Model"].(string); ok {
|
||||
pc.Model = v
|
||||
}
|
||||
if v, ok := m["Timeout"].(int); ok {
|
||||
pc.Timeout = v
|
||||
}
|
||||
if v, ok := m["ContextWindowTokens"].(int); ok {
|
||||
pc.ContextWindowTokens = v
|
||||
}
|
||||
|
||||
return pc, nil
|
||||
}
|
||||
|
||||
@@ -135,3 +135,137 @@ func (s *State) ListProfiles() []ProviderConfig {
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
// UpdateProvider updates an existing provider's configuration
|
||||
func (s *State) UpdateProvider(config ProviderConfig) error {
|
||||
name := strings.TrimSpace(config.Name)
|
||||
if name == "" {
|
||||
return errors.New("llm provider name cannot be empty")
|
||||
}
|
||||
if strings.TrimSpace(config.APIKey) == "" {
|
||||
return fmt.Errorf("llm provider %s api_key is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.Model) == "" {
|
||||
return fmt.Errorf("llm provider %s model is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
return fmt.Errorf("llm provider %s base_url is required", name)
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = 120
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.profiles[name]; !ok {
|
||||
return fmt.Errorf("llm provider not found: %s", name)
|
||||
}
|
||||
|
||||
// Create new client with updated config
|
||||
s.profiles[name] = &Profile{
|
||||
Config: config,
|
||||
Client: ark.NewClientWithApiKey(
|
||||
config.APIKey,
|
||||
ark.WithBaseUrl(config.BaseURL),
|
||||
ark.WithTimeout(time.Duration(config.Timeout)*time.Second),
|
||||
),
|
||||
}
|
||||
|
||||
// Update active status if needed
|
||||
if config.Active && s.activeName != name {
|
||||
s.activeName = name
|
||||
} else if !config.Active && s.activeName == name {
|
||||
// If we're deactivating the current active provider, switch to the first available
|
||||
for _, otherName := range s.order {
|
||||
if otherName != name {
|
||||
s.activeName = otherName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddProvider adds a new provider to the state
|
||||
func (s *State) AddProvider(config ProviderConfig) error {
|
||||
name := strings.TrimSpace(config.Name)
|
||||
if name == "" {
|
||||
return errors.New("llm provider name cannot be empty")
|
||||
}
|
||||
if strings.TrimSpace(config.APIKey) == "" {
|
||||
return fmt.Errorf("llm provider %s api_key is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.Model) == "" {
|
||||
return fmt.Errorf("llm provider %s model is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
return fmt.Errorf("llm provider %s base_url is required", name)
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = 120
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.profiles[name]; ok {
|
||||
return fmt.Errorf("llm provider already exists: %s", name)
|
||||
}
|
||||
|
||||
s.profiles[name] = &Profile{
|
||||
Config: config,
|
||||
Client: ark.NewClientWithApiKey(
|
||||
config.APIKey,
|
||||
ark.WithBaseUrl(config.BaseURL),
|
||||
ark.WithTimeout(time.Duration(config.Timeout)*time.Second),
|
||||
),
|
||||
}
|
||||
s.order = append(s.order, name)
|
||||
|
||||
// Set as active if it's the first one or explicitly marked active
|
||||
if len(s.profiles) == 1 || config.Active {
|
||||
s.activeName = name
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProvider removes a provider from the state
|
||||
func (s *State) RemoveProvider(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return errors.New("llm provider name cannot be empty")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.profiles[name]; !ok {
|
||||
return fmt.Errorf("llm provider not found: %s", name)
|
||||
}
|
||||
|
||||
// Don't allow removing the last provider
|
||||
if len(s.profiles) == 1 {
|
||||
return errors.New("cannot remove the last llm provider")
|
||||
}
|
||||
|
||||
delete(s.profiles, name)
|
||||
|
||||
// Remove from order
|
||||
newOrder := make([]string, 0, len(s.order)-1)
|
||||
for _, n := range s.order {
|
||||
if n != name {
|
||||
newOrder = append(newOrder, n)
|
||||
}
|
||||
}
|
||||
s.order = newOrder
|
||||
|
||||
// If we removed the active provider, switch to the first available
|
||||
if s.activeName == name {
|
||||
s.activeName = s.order[0]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateProvider(t *testing.T) {
|
||||
// Create initial state with one provider
|
||||
configs := []ProviderConfig{
|
||||
{
|
||||
Name: "test-provider",
|
||||
Active: true,
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://test.example.com",
|
||||
Model: "test-model",
|
||||
Timeout: 120,
|
||||
ContextWindowTokens: 4096,
|
||||
},
|
||||
}
|
||||
|
||||
state, err := NewState(configs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create state: %v", err)
|
||||
}
|
||||
|
||||
// Get the initial profile
|
||||
profile := state.ActiveProfile()
|
||||
if profile.Config.APIKey != "test-key" {
|
||||
t.Errorf("expected APIKey 'test-key', got '%s'", profile.Config.APIKey)
|
||||
}
|
||||
|
||||
// Update the provider with new config
|
||||
updatedConfig := ProviderConfig{
|
||||
Name: "test-provider",
|
||||
Active: true,
|
||||
APIKey: "new-key",
|
||||
BaseURL: "https://new.example.com",
|
||||
Model: "new-model",
|
||||
Timeout: 60,
|
||||
ContextWindowTokens: 8192,
|
||||
}
|
||||
|
||||
err = state.UpdateProvider(updatedConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update provider: %v", err)
|
||||
}
|
||||
|
||||
// Verify the update
|
||||
profile = state.ActiveProfile()
|
||||
if profile.Config.APIKey != "new-key" {
|
||||
t.Errorf("expected updated APIKey 'new-key', got '%s'", profile.Config.APIKey)
|
||||
}
|
||||
if profile.Config.BaseURL != "https://new.example.com" {
|
||||
t.Errorf("expected updated BaseURL 'https://new.example.com', got '%s'", profile.Config.BaseURL)
|
||||
}
|
||||
if profile.Config.Model != "new-model" {
|
||||
t.Errorf("expected updated Model 'new-model', got '%s'", profile.Config.Model)
|
||||
}
|
||||
if profile.Config.Timeout != 60 {
|
||||
t.Errorf("expected updated Timeout 60, got %d", profile.Config.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddProvider(t *testing.T) {
|
||||
// Create initial state with one provider
|
||||
configs := []ProviderConfig{
|
||||
{
|
||||
Name: "provider1",
|
||||
Active: true,
|
||||
APIKey: "key1",
|
||||
BaseURL: "https://example1.com",
|
||||
Model: "model1",
|
||||
Timeout: 120,
|
||||
ContextWindowTokens: 4096,
|
||||
},
|
||||
}
|
||||
|
||||
state, err := NewState(configs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create state: %v", err)
|
||||
}
|
||||
|
||||
// Add a second provider
|
||||
newConfig := ProviderConfig{
|
||||
Name: "provider2",
|
||||
Active: false,
|
||||
APIKey: "key2",
|
||||
BaseURL: "https://example2.com",
|
||||
Model: "model2",
|
||||
Timeout: 60,
|
||||
ContextWindowTokens: 8192,
|
||||
}
|
||||
|
||||
err = state.AddProvider(newConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add provider: %v", err)
|
||||
}
|
||||
|
||||
// Verify the new provider exists
|
||||
profile, err := state.GetProfile("provider2")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get provider2: %v", err)
|
||||
}
|
||||
if profile.Config.Name != "provider2" {
|
||||
t.Errorf("expected name 'provider2', got '%s'", profile.Config.Name)
|
||||
}
|
||||
|
||||
// Verify active provider is still provider1
|
||||
activeProfile := state.ActiveProfile()
|
||||
if activeProfile.Config.Name != "provider1" {
|
||||
t.Errorf("expected active provider 'provider1', got '%s'", activeProfile.Config.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveProvider(t *testing.T) {
|
||||
// Create initial state with two providers
|
||||
configs := []ProviderConfig{
|
||||
{
|
||||
Name: "provider1",
|
||||
Active: true,
|
||||
APIKey: "key1",
|
||||
BaseURL: "https://example1.com",
|
||||
Model: "model1",
|
||||
Timeout: 120,
|
||||
ContextWindowTokens: 4096,
|
||||
},
|
||||
{
|
||||
Name: "provider2",
|
||||
Active: false,
|
||||
APIKey: "key2",
|
||||
BaseURL: "https://example2.com",
|
||||
Model: "model2",
|
||||
Timeout: 60,
|
||||
ContextWindowTokens: 8192,
|
||||
},
|
||||
}
|
||||
|
||||
state, err := NewState(configs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create state: %v", err)
|
||||
}
|
||||
|
||||
// Remove provider2
|
||||
err = state.RemoveProvider("provider2")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to remove provider: %v", err)
|
||||
}
|
||||
|
||||
// Verify provider2 is gone
|
||||
_, err = state.GetProfile("provider2")
|
||||
if err == nil {
|
||||
t.Error("expected error when getting removed provider, got nil")
|
||||
}
|
||||
|
||||
// Try to remove the last provider (should fail)
|
||||
err = state.RemoveProvider("provider1")
|
||||
if err == nil {
|
||||
t.Error("expected error when removing last provider, got nil")
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,14 @@ import (
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) {
|
||||
// LLMProviderReloader is the interface for reloading LLM provider configuration
|
||||
type LLMProviderReloader interface {
|
||||
ReloadLLMProvider(config interface{}) error
|
||||
AddLLMProvider(config interface{}) error
|
||||
RemoveLLMProvider(name string) error
|
||||
}
|
||||
|
||||
func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store, aiService LLMProviderReloader) {
|
||||
group := r.Group("/llm")
|
||||
{
|
||||
// LLM Message Queue
|
||||
@@ -27,9 +34,9 @@ func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) {
|
||||
// LLM Providers
|
||||
group.GET("/providers", handleListLLMProviders(store))
|
||||
group.GET("/providers/:name", handleGetLLMProvider(store))
|
||||
group.POST("/providers", handleCreateLLMProvider(store))
|
||||
group.PUT("/providers/:name", handleUpdateLLMProvider(store))
|
||||
group.DELETE("/providers/:name", handleDeleteLLMProvider(store))
|
||||
group.POST("/providers", handleCreateLLMProvider(store, aiService))
|
||||
group.PUT("/providers/:name", handleUpdateLLMProvider(store, aiService))
|
||||
group.DELETE("/providers/:name", handleDeleteLLMProvider(store, aiService))
|
||||
|
||||
// LLM Tool Router
|
||||
group.GET("/tool-router", handleGetLLMToolRouter(store))
|
||||
@@ -254,7 +261,7 @@ func handleGetLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
func handleCreateLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
@@ -290,11 +297,33 @@ func handleCreateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Reload AI service with new provider
|
||||
if aiService != nil {
|
||||
providerConfig := map[string]interface{}{
|
||||
"Name": record.Name,
|
||||
"Active": record.Active,
|
||||
"APIKey": record.APIKey,
|
||||
"BaseURL": record.BaseURL,
|
||||
"Model": record.Model,
|
||||
"Timeout": record.Timeout,
|
||||
"ContextWindowTokens": record.ContextWindowTokens,
|
||||
}
|
||||
if err := aiService.AddLLMProvider(providerConfig); err != nil {
|
||||
// Log warning but don't fail the request - database is already updated
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"item": llmProviderDTO(*record),
|
||||
"warning": "provider created but failed to reload AI service: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
func handleUpdateLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
@@ -355,11 +384,33 @@ func handleUpdateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Reload AI service with updated provider
|
||||
if aiService != nil {
|
||||
providerConfig := map[string]interface{}{
|
||||
"Name": record.Name,
|
||||
"Active": record.Active,
|
||||
"APIKey": record.APIKey,
|
||||
"BaseURL": record.BaseURL,
|
||||
"Model": record.Model,
|
||||
"Timeout": record.Timeout,
|
||||
"ContextWindowTokens": record.ContextWindowTokens,
|
||||
}
|
||||
if err := aiService.ReloadLLMProvider(providerConfig); err != nil {
|
||||
// Log warning but don't fail the request - database is already updated
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"item": llmProviderDTO(*record),
|
||||
"warning": "provider updated but failed to reload AI service: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
func handleDeleteLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
@@ -372,6 +423,18 @@ func handleDeleteLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove provider from AI service
|
||||
if aiService != nil {
|
||||
if err := aiService.RemoveLLMProvider(name); err != nil {
|
||||
// Log warning but don't fail the request - database is already updated
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"warning": "provider deleted but failed to reload AI service: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CountActiveNodes 统计指定时间后有更新记录的节点数量
|
||||
func (s *Store) CountActiveNodes(since time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.Model(&NodeInfoRecord{}).
|
||||
Where("updated_at >= ?", since).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// CountActiveUsers 统计指定时间后发送过消息的唯一用户数(按 from_id 去重)
|
||||
func (s *Store) CountActiveUsers(since time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.Model(&TextMessageRecord{}).
|
||||
Where("created_at >= ?", since).
|
||||
Distinct("from_id").
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func TestMapTileProxyFetchesAndCaches(t *testing.T) {
|
||||
}
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, false, st, nil, nil, nil, nil, nil, nil)
|
||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, false, st, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3"
|
||||
for i := 0; i < 2; i++ {
|
||||
@@ -75,7 +75,7 @@ func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) {
|
||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil)
|
||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
cases := []string{
|
||||
"/api/map/" + row.URLTemplateHash + "?y=0&z=0",
|
||||
@@ -106,7 +106,7 @@ func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) {
|
||||
t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil)
|
||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
cases := []string{
|
||||
"/api/map/not-a-hash?x=0&y=0&z=0",
|
||||
@@ -147,7 +147,7 @@ func TestMapTileProxyUpstreamStatus(t *testing.T) {
|
||||
t.Fatalf("CreateMapTileSource(500) error = %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil)
|
||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
cases := []struct {
|
||||
url string
|
||||
|
||||
+13
-6
@@ -27,10 +27,17 @@ import (
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
func NewHTTPServer(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *http.Server {
|
||||
// LLMProviderReloader is the interface for reloading LLM provider configuration
|
||||
type LLMProviderReloader interface {
|
||||
ReloadLLMProvider(config interface{}) error
|
||||
AddLLMProvider(config interface{}) error
|
||||
RemoveLLMProvider(name string) error
|
||||
}
|
||||
|
||||
func NewHTTPServer(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||
Handler: NewRouter(cfg, consoleLog, store, sessions, mqttStatus, blocking, forwarder, settings, botSender),
|
||||
Handler: NewRouter(cfg, consoleLog, store, sessions, mqttStatus, blocking, forwarder, settings, botSender, aiService),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +67,7 @@ func ServeUnixSocket(server *http.Server, socketPath string) error {
|
||||
return server.Serve(listener)
|
||||
}
|
||||
|
||||
func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *gin.Engine {
|
||||
func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) *gin.Engine {
|
||||
r := gin.New()
|
||||
if consoleLog {
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
@@ -69,7 +76,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store,
|
||||
}
|
||||
api := r.Group("/api")
|
||||
registerAPIRoutes(api, store, cfg.MapTileCacheDir)
|
||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender)
|
||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender, aiService)
|
||||
registerStaticRoutes(r, cfg.StaticDir)
|
||||
return r
|
||||
}
|
||||
@@ -163,7 +170,7 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
|
||||
})
|
||||
}
|
||||
|
||||
func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) {
|
||||
func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) {
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -230,7 +237,7 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma
|
||||
mappkg.RegisterAdminRoutes(protected, store)
|
||||
helppkg.RegisterAdminRoutes(protected, store)
|
||||
botpkg.RegisterRoutes(protected, store, botSender)
|
||||
llmadminpkg.RegisterRoutes(protected, store)
|
||||
llmadminpkg.RegisterRoutes(protected, store, aiService)
|
||||
protected.GET("/me", func(c *gin.Context) {
|
||||
claims := c.MustGet("admin_claims").(*auth.SessionClaims)
|
||||
c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserDTO{Username: claims.Username, Role: claims.Role}})
|
||||
|
||||
@@ -458,7 +458,7 @@ func run(cfg *configpkg.Config) error {
|
||||
return err
|
||||
}
|
||||
mqttStatus := webpkg.MQTTRuntimeStatus{Server: server, Address: mqttAddr, TLS: cfg.MQTT.TLS.Enabled, Stats: messageStats, ClientStats: clientStats, DBQueue: dbQueue}
|
||||
handler := webpkg.NewRouter(cfg.Web, cfg.ConsoleLog.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender)
|
||||
handler := webpkg.NewRouter(cfg.Web, cfg.ConsoleLog.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender, aiService)
|
||||
webAddresses := []string{}
|
||||
if cfg.Web.PortEnabled {
|
||||
httpServer := &http.Server{
|
||||
|
||||
Reference in New Issue
Block a user