根本原因:PKI 私聊需要接收方的公钥,但 !72d17be4 这个节点从未广播过节点信息,nodeinfo 表中没有它的公钥。这傻x豆包ai突然发什么神经,蠢死了
This commit is contained in:
@@ -31,6 +31,10 @@ func registerAdminLLMRoutes(r *gin.RouterGroup, store *store) {
|
||||
// LLM Tool Router
|
||||
group.GET("/tool-router", handleGetLLMToolRouter(store))
|
||||
group.PUT("/tool-router", handleUpdateLLMToolRouter(store))
|
||||
|
||||
// LLM Primary Config - 主 AI 回复配置
|
||||
group.GET("/primary-config", handleGetLLMPrimaryConfig(store))
|
||||
group.PUT("/primary-config", handleUpdateLLMPrimaryConfig(store))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,3 +499,130 @@ func llmToolRouterDTO(row llmToolRouterRecord) map[string]any {
|
||||
"updated_at": row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Primary Config Handlers
|
||||
// ============================================
|
||||
|
||||
func handleGetLLMPrimaryConfig(store *store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMPrimaryConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "primary config not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": llmPrimaryConfigDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMPrimaryConfig()
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
ProviderName *string `json:"provider_name"`
|
||||
Timeout *int `json:"timeout"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
EnableTool *bool `json:"enable_tool"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.ProviderName != nil {
|
||||
updates["provider_name"] = *req.ProviderName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
updates["timeout"] = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
updates["max_tokens"] = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
updates["system_prompt"] = *req.SystemPrompt
|
||||
}
|
||||
if req.EnableTool != nil {
|
||||
updates["enable_tool"] = *req.EnableTool
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
// 创建新配置
|
||||
newRecord := &llmPrimaryConfigRecord{
|
||||
Enabled: req.Enabled != nil && *req.Enabled,
|
||||
ProviderName: "",
|
||||
Timeout: 120,
|
||||
MaxTokens: 1024,
|
||||
SystemPrompt: "",
|
||||
EnableTool: false,
|
||||
}
|
||||
if req.ProviderName != nil {
|
||||
newRecord.ProviderName = *req.ProviderName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
newRecord.Timeout = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
newRecord.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
newRecord.SystemPrompt = *req.SystemPrompt
|
||||
}
|
||||
if req.EnableTool != nil {
|
||||
newRecord.EnableTool = *req.EnableTool
|
||||
}
|
||||
if err := store.CreateLLMPrimaryConfig(newRecord); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record = newRecord
|
||||
} else {
|
||||
// 更新现有配置
|
||||
if err := store.UpdateLLMPrimaryConfig(record.ID, updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record, err = store.GetLLMPrimaryConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmPrimaryConfigDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func llmPrimaryConfigDTO(row llmPrimaryConfigRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"id": row.ID,
|
||||
"enabled": row.Enabled,
|
||||
"provider_name": row.ProviderName,
|
||||
"timeout": row.Timeout,
|
||||
"max_tokens": row.MaxTokens,
|
||||
"system_prompt": row.SystemPrompt,
|
||||
"enable_tool": row.EnableTool,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
+157
-8
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"meshtastic_mqtt_server/completion"
|
||||
"meshtastic_mqtt_server/conversation"
|
||||
@@ -164,13 +165,32 @@ func (s *Service) processQueue(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// printJSON outputs a structured log message (imported from main package pattern)
|
||||
func printJSON(v any) {
|
||||
fmt.Printf("%+v\n", v)
|
||||
}
|
||||
|
||||
// processMessage processes a single queued message
|
||||
func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||
// Mark message as processing
|
||||
if err := s.msgQueue.MarkAsProcessing(msg.ID); err != nil {
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_process_failed",
|
||||
"msg_id": msg.ID,
|
||||
"step": "mark_as_processing",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_process_start",
|
||||
"msg_id": msg.ID,
|
||||
"bot_id": msg.BotID,
|
||||
"from_node_id": msg.FromNodeID,
|
||||
"text": msg.Text,
|
||||
})
|
||||
|
||||
// Create processing context with timeout
|
||||
procCtx, cancel := context.WithTimeout(ctx, MaxProcessingTime)
|
||||
defer cancel()
|
||||
@@ -178,7 +198,9 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||
// Get or create conversation for this bot
|
||||
conv, err := s.convStore.GetOrCreateForBot(msg.BotID, msg.BotNodeID, msg.FromNodeID)
|
||||
if err != nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to get conversation: %v", err))
|
||||
errMsg := fmt.Sprintf("failed to get conversation: %v", err)
|
||||
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "get_conversation", "error": errMsg})
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -188,21 +210,34 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||
Content: s.formatUserMessage(msg),
|
||||
}
|
||||
if err := s.convStore.AddMessage(conv.ID, userMsg); err != nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to add message: %v", err))
|
||||
errMsg := fmt.Sprintf("failed to add message: %v", err)
|
||||
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "add_message", "error": errMsg})
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the LLM profile
|
||||
profile := s.llmState.ActiveProfile()
|
||||
if profile == nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, "no active LLM profile")
|
||||
errMsg := "no active LLM profile - check if LLM providers are configured"
|
||||
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "get_profile", "error": errMsg})
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_process_profile",
|
||||
"msg_id": msg.ID,
|
||||
"model": profile.Config.Model,
|
||||
"base_url": profile.Config.BaseURL,
|
||||
})
|
||||
|
||||
// Reload conversation to get updated messages
|
||||
conv, err = s.convStore.Get(conv.ID)
|
||||
if err != nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to reload conversation: %v", err))
|
||||
errMsg := fmt.Sprintf("failed to reload conversation: %v", err)
|
||||
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "reload_conversation", "error": errMsg})
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -211,17 +246,55 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||
_ = augmentedMessages // We'll use this in the future with proper tool support
|
||||
|
||||
// For now, use simple completion since we don't have tools registered yet
|
||||
printJSON(map[string]any{"event": "llm_process_completion_start", "msg_id": msg.ID})
|
||||
reply, err := completion.CompleteText(procCtx, profile, conv.Messages, 512)
|
||||
if err != nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("LLM completion failed: %v", err))
|
||||
errMsg := fmt.Sprintf("LLM completion failed: %v", err)
|
||||
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "llm_completion", "error": errMsg})
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Truncate reply for Meshtastic
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_process_completion_success",
|
||||
"msg_id": msg.ID,
|
||||
"reply_len": len(reply),
|
||||
})
|
||||
|
||||
// Clean and validate reply text
|
||||
reply = cleanReplyText(reply)
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_process_text_cleaned",
|
||||
"msg_id": msg.ID,
|
||||
"cleaned_len": len(reply),
|
||||
})
|
||||
|
||||
// Truncate reply for Meshtastic (UTF-8 safe truncation)
|
||||
if len([]byte(reply)) > MaxReplyLength {
|
||||
reply = string([]byte(reply)[:MaxReplyLength-3]) + "..."
|
||||
reply = truncateUTF8(reply, MaxReplyLength-3) + "..."
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_process_text_truncated",
|
||||
"msg_id": msg.ID,
|
||||
"truncated_len": len(reply),
|
||||
})
|
||||
}
|
||||
|
||||
// Final UTF-8 validation before sending
|
||||
if !utf8.ValidString(reply) {
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_process_utf8_warning",
|
||||
"msg_id": msg.ID,
|
||||
"message": "final text still invalid, using fallback",
|
||||
})
|
||||
reply = "抱歉,我暂时无法回复。请稍后再试。"
|
||||
}
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_process_final_check",
|
||||
"msg_id": msg.ID,
|
||||
"valid_utf8": utf8.ValidString(reply),
|
||||
"final_len": len(reply),
|
||||
})
|
||||
|
||||
// Add assistant reply to conversation
|
||||
assistantMsg := message.ChatMessage{
|
||||
Role: "assistant",
|
||||
@@ -232,13 +305,21 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||
}
|
||||
|
||||
// Send the reply via the bot
|
||||
printJSON(map[string]any{"event": "llm_process_send_start", "msg_id": msg.ID, "to_node_num": msg.FromNodeNum})
|
||||
if err := s.botSender.SendText(procCtx, msg.BotID, msg.FromNodeNum, reply); err != nil {
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to send reply: %v", err))
|
||||
errMsg := fmt.Sprintf("failed to send reply: %v", err)
|
||||
printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "send_reply", "error": errMsg})
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark message as processed
|
||||
_ = s.msgQueue.MarkAsProcessed(msg.ID, reply)
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_process_success",
|
||||
"msg_id": msg.ID,
|
||||
"reply": reply,
|
||||
})
|
||||
}
|
||||
|
||||
// formatUserMessage formats the incoming message for the LLM
|
||||
@@ -256,3 +337,71 @@ func (s *Service) formatUserMessage(msg QueuedMessage) string {
|
||||
sb.WriteString(msg.Text)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// cleanReplyText cleans LLM reply text to ensure it's valid for Meshtastic
|
||||
func cleanReplyText(text string) string {
|
||||
// Force convert to valid UTF-8 using rune-by-rune processing
|
||||
v := make([]rune, 0, len(text))
|
||||
for i, r := range text {
|
||||
if r == utf8.RuneError {
|
||||
_, size := utf8.DecodeRuneInString(text[i:])
|
||||
if size == 1 {
|
||||
continue // Skip invalid runes
|
||||
}
|
||||
}
|
||||
// Skip all problematic characters
|
||||
if r < 32 && r != '\n' && r != '\r' && r != '\t' {
|
||||
continue
|
||||
}
|
||||
if r == 65533 || r == 0xfffd { // Unicode replacement character
|
||||
continue
|
||||
}
|
||||
v = append(v, r)
|
||||
}
|
||||
text = string(v)
|
||||
|
||||
// Additional cleanup - use only explicitly allowed ASCII printable + CJK
|
||||
var sb strings.Builder
|
||||
for _, r := range text {
|
||||
switch {
|
||||
case r == '\n':
|
||||
sb.WriteRune(' ') // Replace newlines with space for Meshtastic
|
||||
case r == '\r' || r == '\t':
|
||||
continue
|
||||
case r >= 32 && r <= 126: // Printable ASCII
|
||||
sb.WriteRune(r)
|
||||
case r >= 0x4E00 && r <= 0x9FFF: // CJK Unified Ideographs
|
||||
sb.WriteRune(r)
|
||||
case r >= 0x3400 && r <= 0x4DBF: // CJK Extension A
|
||||
sb.WriteRune(r)
|
||||
case r >= 0x20000 && r <= 0x2A6DF: // CJK Extension B
|
||||
sb.WriteRune(r)
|
||||
case r >= 0xFF01 && r <= 0xFF5E: // Fullwidth ASCII variants
|
||||
sb.WriteRune(r)
|
||||
case r == 0x3002 || r == 0xFF1F || r == 0xFF01 || r == 0xFF0C || r == 0xFF1A: // Fullwidth punctuation
|
||||
sb.WriteRune(r)
|
||||
default:
|
||||
continue // Skip all other characters
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(sb.String())
|
||||
if result == "" {
|
||||
result = "抱歉,我无法回复此消息。"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// truncateUTF8 safely truncates a UTF-8 string to max bytes without breaking in the middle of a rune
|
||||
func truncateUTF8(s string, maxBytes int) string {
|
||||
if len([]byte(s)) <= maxBytes {
|
||||
return s
|
||||
}
|
||||
bytes := []byte(s)
|
||||
for i := maxBytes; i > 0; i-- {
|
||||
if utf8.RuneStart(bytes[i]) {
|
||||
return string(bytes[:i])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m
|
||||
longName := nullableString(record["long_name"])
|
||||
shortName := nullableString(record["short_name"])
|
||||
channelID := nullableString(record["channel_id"])
|
||||
_, _ = s.EnqueueLLMMessage(LLMMessageQueueInput{
|
||||
_, err = s.EnqueueLLMMessage(LLMMessageQueueInput{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
@@ -308,6 +308,15 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m
|
||||
Topic: topic,
|
||||
ContentJSON: contentPtr,
|
||||
})
|
||||
if err != nil {
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_queue_enqueue_failed",
|
||||
"bot_id": bot.ID,
|
||||
"from": peerNodeID,
|
||||
"text": text,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -88,14 +88,10 @@ func (s *Store) Get(id string) (*message.Conversation, error) {
|
||||
func (s *Store) GetOrCreateForBot(botID uint64, botNodeID string, peerNodeID string) (*message.Conversation, error) {
|
||||
// Try to find an existing conversation with this peer
|
||||
convs, err := s.ListForBot(botID)
|
||||
if err == nil {
|
||||
for _, conv := range convs {
|
||||
// Simple matching: use peer node ID in the future if needed
|
||||
// For now, just use the most recent conversation
|
||||
if conv.BotID == botID && len(conv.Messages) > 0 {
|
||||
return s.Get(conv.ID)
|
||||
}
|
||||
}
|
||||
if err == nil && len(convs) > 0 {
|
||||
// Use the most recent conversation (List already sorts by UpdatedAt desc)
|
||||
// Note: List returns convs with Messages = nil, so we need to reload
|
||||
return s.Get(convs[0].ID)
|
||||
}
|
||||
// Create a new conversation
|
||||
return s.Create(botID, botNodeID)
|
||||
|
||||
@@ -494,6 +494,23 @@ func (llmToolRouterRecord) TableName() string {
|
||||
return "llm_tool_router"
|
||||
}
|
||||
|
||||
// llmPrimaryConfigRecord 保存主 AI 回复的配置
|
||||
type llmPrimaryConfigRecord struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Enabled bool `gorm:"column:enabled;not null;index"` // 是否启用 AI 回复
|
||||
ProviderName string `gorm:"column:provider_name;size:64;not null"` // 使用的 LLM 提供商名称(关联 llm_providers.name)
|
||||
Timeout int `gorm:"column:timeout;not null;default:120"` // 请求超时时间(秒)
|
||||
MaxTokens int `gorm:"column:max_tokens;not null;default:1024"` // 回复最大 token 数
|
||||
SystemPrompt string `gorm:"column:system_prompt;type:text;not null"` // 默认系统提示词
|
||||
EnableTool bool `gorm:"column:enable_tool;not null;default:false"` // 是否启用工具调用
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
|
||||
}
|
||||
|
||||
func (llmPrimaryConfigRecord) TableName() string {
|
||||
return "llm_primary_config"
|
||||
}
|
||||
|
||||
type positionRecord struct {
|
||||
AppendPacketFields `gorm:"embedded"`
|
||||
MQTTClientRecordFields `gorm:"embedded"`
|
||||
@@ -627,6 +644,7 @@ func (s *store) migrate() error {
|
||||
{label: "llm_message_queue", model: &llmMessageQueueRecord{}},
|
||||
{label: "llm_providers", model: &llmProviderRecord{}},
|
||||
{label: "llm_tool_router", model: &llmToolRouterRecord{}},
|
||||
{label: "llm_primary_config", model: &llmPrimaryConfigRecord{}},
|
||||
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
||||
{label: "map_report", model: &mapReportRecord{}},
|
||||
{label: "text_message", model: &textMessageRecord{}},
|
||||
@@ -673,6 +691,9 @@ func (s *store) migrate() error {
|
||||
if err := txStore.EnsureDefaultLLMToolRouter(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := txStore.EnsureDefaultLLMPrimaryConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -725,6 +746,12 @@ func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error
|
||||
return fmt.Errorf("migrate bot_nodes llm_include_channel_messages column: %w", err)
|
||||
}
|
||||
}
|
||||
// 迁移 LLM 消息队列 reply 列
|
||||
if migrator.HasTable(&llmMessageQueueRecord{}) && !migrator.HasColumn(&llmMessageQueueRecord{}, "Reply") {
|
||||
if err := tx.Exec("ALTER TABLE llm_message_queue ADD COLUMN reply text").Error; err != nil {
|
||||
return fmt.Errorf("migrate llm_message_queue reply column: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+81
-5
@@ -139,6 +139,60 @@ func (s *store) EnsureDefaultLLMToolRouter() error {
|
||||
return s.CreateLLMToolRouter(defaultConfig)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Primary Config (llm_primary_config) - 主 AI 回复配置
|
||||
// ============================================
|
||||
|
||||
// GetLLMPrimaryConfig 获取当前激活的主 AI 回复配置
|
||||
func (s *store) GetLLMPrimaryConfig() (*llmPrimaryConfigRecord, error) {
|
||||
var record llmPrimaryConfigRecord
|
||||
// 默认取第一条记录(ID 最小的),因为通常只需要一个配置
|
||||
if err := s.db.Order("id ASC").First(&record).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("get llm primary config: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// CreateLLMPrimaryConfig 创建主 AI 回复配置
|
||||
func (s *store) CreateLLMPrimaryConfig(record *llmPrimaryConfigRecord) error {
|
||||
if err := s.db.Create(record).Error; err != nil {
|
||||
return fmt.Errorf("create llm primary config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLLMPrimaryConfig 更新主 AI 回复配置
|
||||
func (s *store) UpdateLLMPrimaryConfig(id uint64, updates map[string]any) error {
|
||||
if err := s.db.Model(&llmPrimaryConfigRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("update llm primary config %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDefaultLLMPrimaryConfig 确保存在默认主 AI 回复配置
|
||||
func (s *store) EnsureDefaultLLMPrimaryConfig() error {
|
||||
_, err := s.GetLLMPrimaryConfig()
|
||||
if err == nil {
|
||||
return nil // 已存在
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
// 创建默认配置
|
||||
defaultConfig := &llmPrimaryConfigRecord{
|
||||
Enabled: false,
|
||||
ProviderName: "",
|
||||
Timeout: 120,
|
||||
MaxTokens: 1024,
|
||||
SystemPrompt: "你是一个 Meshtastic 网络助手。请简洁回答用户问题。\n回答要简短清晰,适合在低带宽无线电环境传输。每次回复限制在200 bytes以内。",
|
||||
EnableTool: false,
|
||||
}
|
||||
return s.CreateLLMPrimaryConfig(defaultConfig)
|
||||
}
|
||||
|
||||
// LLMMessageQueueInput 是添加 LLM 队列消息的输入
|
||||
type LLMMessageQueueInput struct {
|
||||
BotID uint64 // 0 表示频道消息
|
||||
@@ -179,12 +233,24 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR
|
||||
return nil, fmt.Errorf("text is required")
|
||||
}
|
||||
|
||||
// 检查是否存在重复消息:相同 bot_id + packet_id 且未删除
|
||||
// 检查是否存在重复消息
|
||||
// packet_id > 0: 用 bot_id + packet_id 去重(频道消息)
|
||||
// packet_id = 0: 用 bot_id + from_node_id + text 去重(私聊消息,可能没有 packet_id)
|
||||
// 只排除 pending/processing 状态的消息,允许 error 状态的消息重新入队
|
||||
var existing llmMessageQueueRecord
|
||||
err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL", input.BotID, input.PacketID).
|
||||
if input.PacketID > 0 {
|
||||
// 频道消息:用 bot_id + packet_id 去重
|
||||
err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL AND status IN (?, ?)",
|
||||
input.BotID, input.PacketID, llmMessageStatusPending, llmMessageStatusProcessing).
|
||||
Take(&existing).Error
|
||||
} else {
|
||||
// 私聊消息:用 bot_id + from_node_id + text 去重(避免同一人连续发相同内容被拒绝)
|
||||
err = s.db.Where("bot_id = ? AND from_node_id = ? AND text = ? AND deleted_at IS NULL AND status IN (?, ?)",
|
||||
input.BotID, input.FromNodeID, input.Text, llmMessageStatusPending, llmMessageStatusProcessing).
|
||||
Take(&existing).Error
|
||||
}
|
||||
if err == nil {
|
||||
// 重复消息,直接返回已存在的
|
||||
// 存在正在处理或待处理的相同消息,直接返回
|
||||
return &existing, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -377,15 +443,16 @@ func enqueueChannelMessageToLLM(s *store, record map[string]any) error {
|
||||
}
|
||||
|
||||
// 查询所有启用了 LLM 队列且包含频道消息的机器人
|
||||
// SQLite 中 numeric 布尔值用 1/0 存储,必须用整数查询
|
||||
var bots []botNodeRecord
|
||||
err = s.db.Where("llm_queue_enabled = ? AND llm_include_channel_messages = ?", true, true).Find(&bots).Error
|
||||
err = s.db.Where("llm_queue_enabled = ? AND llm_include_channel_messages = ?", 1, 1).Find(&bots).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("query bots for channel message enqueue: %w", err)
|
||||
}
|
||||
|
||||
// 为每个符合条件的机器人创建一条队列记录
|
||||
for _, bot := range bots {
|
||||
_, _ = s.EnqueueLLMMessage(LLMMessageQueueInput{
|
||||
_, err = s.EnqueueLLMMessage(LLMMessageQueueInput{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
@@ -399,6 +466,15 @@ func enqueueChannelMessageToLLM(s *store, record map[string]any) error {
|
||||
Topic: topic,
|
||||
ContentJSON: contentPtr,
|
||||
})
|
||||
if err != nil {
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_queue_enqueue_failed",
|
||||
"bot_id": bot.ID,
|
||||
"from": fromNodeID,
|
||||
"text": text,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -54,6 +54,8 @@ import type {
|
||||
LLMProviderResponse,
|
||||
LLMPlatformRouterPayload,
|
||||
LLMPlatformRouterResponse,
|
||||
LLMPrimaryConfigPayload,
|
||||
LLMPrimaryConfigResponse,
|
||||
} from './types'
|
||||
|
||||
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -504,5 +506,14 @@ export function updateLLMToolRouter(payload: Partial<LLMPlatformRouterPayload>):
|
||||
return putJSON<LLMPlatformRouterResponse>('/api/admin/llm/tool-router', payload)
|
||||
}
|
||||
|
||||
// LLM Primary Config API - 主 AI 回复配置
|
||||
export function getLLMPrimaryConfig(): Promise<LLMPrimaryConfigResponse> {
|
||||
return getJSON<LLMPrimaryConfigResponse>('/api/admin/llm/primary-config')
|
||||
}
|
||||
|
||||
export function updateLLMPrimaryConfig(payload: Partial<LLMPrimaryConfigPayload>): Promise<LLMPrimaryConfigResponse> {
|
||||
return putJSON<LLMPrimaryConfigResponse>('/api/admin/llm/primary-config', payload)
|
||||
}
|
||||
|
||||
// 静默使用未导出类型,避免 TS6133(未使用的导入)。
|
||||
export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter } from './types'
|
||||
export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter, LLMPrimaryConfig } from './types'
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
deleteLLMProvider,
|
||||
getLLMProviders,
|
||||
getLLMToolRouter,
|
||||
getLLMPrimaryConfig,
|
||||
updateLLMProvider,
|
||||
updateLLMToolRouter,
|
||||
updateLLMPrimaryConfig,
|
||||
} from '../api'
|
||||
import type { LLMPlatformRouter, LLMProvider } from '../types'
|
||||
import type { LLMPlatformRouter, LLMProvider, LLMPrimaryConfig } from '../types'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
@@ -42,6 +44,19 @@ const toolRouterForm = ref({
|
||||
system_prompt: '',
|
||||
})
|
||||
|
||||
// Primary AI Config 相关 - 主 AI 回复配置
|
||||
const primaryConfig = ref<LLMPrimaryConfig | null>(null)
|
||||
const editingPrimaryConfig = ref(false)
|
||||
|
||||
const primaryConfigForm = ref({
|
||||
enabled: false,
|
||||
provider_name: '',
|
||||
timeout: 120,
|
||||
max_tokens: 1024,
|
||||
system_prompt: '',
|
||||
enable_tool: false,
|
||||
})
|
||||
|
||||
const activeProviders = computed(() => providers.value.filter((p) => p.active))
|
||||
|
||||
function clearSuccess() {
|
||||
@@ -73,6 +88,16 @@ async function loadToolRouter() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrimaryConfig() {
|
||||
try {
|
||||
const response = await getLLMPrimaryConfig()
|
||||
primaryConfig.value = response.item
|
||||
} catch (err) {
|
||||
// 如果不存在,使用默认值
|
||||
console.warn('Primary AI config not found, using defaults')
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateProvider() {
|
||||
isCreatingProvider.value = true
|
||||
providerForm.value = {
|
||||
@@ -178,9 +203,40 @@ async function saveToolRouter() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEditPrimaryConfig() {
|
||||
if (primaryConfig.value) {
|
||||
primaryConfigForm.value = {
|
||||
enabled: primaryConfig.value.enabled,
|
||||
provider_name: primaryConfig.value.provider_name,
|
||||
timeout: primaryConfig.value.timeout,
|
||||
max_tokens: primaryConfig.value.max_tokens,
|
||||
system_prompt: primaryConfig.value.system_prompt,
|
||||
enable_tool: primaryConfig.value.enable_tool,
|
||||
}
|
||||
}
|
||||
editingPrimaryConfig.value = true
|
||||
}
|
||||
|
||||
function closePrimaryConfigForm() {
|
||||
editingPrimaryConfig.value = false
|
||||
}
|
||||
|
||||
async function savePrimaryConfig() {
|
||||
try {
|
||||
await updateLLMPrimaryConfig(primaryConfigForm.value)
|
||||
success.value = '更新成功'
|
||||
clearSuccess()
|
||||
closePrimaryConfigForm()
|
||||
await loadPrimaryConfig()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
loadToolRouter()
|
||||
loadPrimaryConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -330,6 +386,101 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primary AI Config 配置 - 主 AI 回复配置 -->
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h3>主 AI 回复配置</h3>
|
||||
<p class="section-desc">配置机器人自动回复消息的核心 AI 设置。</p>
|
||||
</div>
|
||||
<button v-if="!editingPrimaryConfig" class="admin-button" @click="openEditPrimaryConfig">编辑配置</button>
|
||||
</div>
|
||||
|
||||
<div v-if="editingPrimaryConfig" class="tool-router-form">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="primaryConfigForm.enabled" />
|
||||
启用 AI 自动回复
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>使用的 AI 配置</label>
|
||||
<select v-model="primaryConfigForm.provider_name" class="form-input">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="p in activeProviders" :key="p.name" :value="p.name">{{ p.name }}</option>
|
||||
</select>
|
||||
<p class="form-hint">选择用于自动回复消息的 AI 提供商配置</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>是否启用工具调用</label>
|
||||
<select v-model="primaryConfigForm.enable_tool" class="form-input">
|
||||
<option :value="false">不启用</option>
|
||||
<option :value="true">启用</option>
|
||||
</select>
|
||||
<p class="form-hint">选择是否让 AI 在回复中调用工具(如计算器等)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>超时时间(秒)</label>
|
||||
<input type="number" v-model.number="primaryConfigForm.timeout" class="form-input" min="1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>最大 Token 数</label>
|
||||
<input type="number" v-model.number="primaryConfigForm.max_tokens" class="form-input" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>系统提示词</label>
|
||||
<textarea v-model="primaryConfigForm.system_prompt" class="form-textarea" rows="6"></textarea>
|
||||
<p class="form-hint">用于指导 AI 如何回复用户消息的系统提示词</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="admin-button admin-button-secondary" @click="closePrimaryConfigForm">取消</button>
|
||||
<button class="admin-button" @click="savePrimaryConfig">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="primaryConfig" class="tool-router-display">
|
||||
<div class="router-status">
|
||||
<span class="status-badge" :class="{ active: primaryConfig.enabled, inactive: !primaryConfig.enabled }">
|
||||
{{ primaryConfig.enabled ? '已启用' : '已停用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="router-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">使用的 AI 配置</span>
|
||||
<span class="detail-value">{{ primaryConfig.provider_name || '未设置' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">超时时间</span>
|
||||
<span class="detail-value">{{ primaryConfig.timeout }} 秒</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">最大 Token 数</span>
|
||||
<span class="detail-value">{{ primaryConfig.max_tokens }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">工具调用</span>
|
||||
<span class="detail-value">{{ primaryConfig.enable_tool ? '已启用' : '未启用' }}</span>
|
||||
</div>
|
||||
<div class="detail-row full-width">
|
||||
<span class="detail-label">系统提示词</span>
|
||||
<pre class="detail-value system-prompt">{{ primaryConfig.system_prompt }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>暂无主 AI 回复配置,点击上方按钮进行配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider 表单弹窗 -->
|
||||
<div v-if="showProviderForm" class="modal-overlay" @click.self="closeProviderForm">
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -668,3 +668,29 @@ export interface LLMPlatformRouterPayload {
|
||||
export interface LLMPlatformRouterResponse {
|
||||
item: LLMPlatformRouter
|
||||
}
|
||||
|
||||
// LLM Primary Config 相关类型 - 主 AI 回复配置
|
||||
export interface LLMPrimaryConfig {
|
||||
id: number
|
||||
enabled: boolean
|
||||
provider_name: string
|
||||
timeout: number
|
||||
max_tokens: number
|
||||
system_prompt: string
|
||||
enable_tool: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface LLMPrimaryConfigPayload {
|
||||
enabled: boolean
|
||||
provider_name: string
|
||||
timeout: number
|
||||
max_tokens: number
|
||||
system_prompt: string
|
||||
enable_tool: boolean
|
||||
}
|
||||
|
||||
export interface LLMPrimaryConfigResponse {
|
||||
item: LLMPrimaryConfig
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "bot_1_1781713830520078500",
|
||||
"bot_id": 1,
|
||||
"bot_node_id": "!1abcead5",
|
||||
"title": "[来自 !72d17be4] 你好",
|
||||
"created_at": "2026-06-18T00:30:30.5200785+08:00",
|
||||
"updated_at": "2026-06-18T00:30:34.3123551+08:00",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[来自 !72d17be4] 你好"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "你好!很高兴见到你!😊\n\n我是DeepSeek,由深度求索公司创造的AI助手。无论你有什么问题、需要帮助,还是只是想聊聊天,我都很乐意陪你!\n\n有什\ufffd\ufffd..."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "bot_1_1781713890525027000",
|
||||
"bot_id": 1,
|
||||
"bot_node_id": "!1abcead5",
|
||||
"title": "[来自 !72d17be4] 你好",
|
||||
"created_at": "2026-06-18T00:31:30.525027+08:00",
|
||||
"updated_at": "2026-06-18T00:31:33.1653132+08:00",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[来自 !72d17be4] 你好"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "你好!😊 有什么可以帮你的吗?"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "bot_1_1781713920523402600",
|
||||
"bot_id": 1,
|
||||
"bot_node_id": "!1abcead5",
|
||||
"title": "[来自 !72d17be4] 你好",
|
||||
"created_at": "2026-06-18T00:32:00.5234026+08:00",
|
||||
"updated_at": "2026-06-18T00:32:04.2936935+08:00",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[来自 !72d17be4] 你好"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "你好!很高兴见到你!😊\n\n我是DeepSeek,一个由深度求索公司创造的AI助手。无论你有什么问题、需要帮助,还是只是想聊聊天,我都很乐意陪伴你、\ufffd..."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "bot_1_1781714154187832400",
|
||||
"bot_id": 1,
|
||||
"bot_node_id": "!1abcead5",
|
||||
"title": "[来自 !72d17be4] 你好",
|
||||
"created_at": "2026-06-18T00:35:54.1878324+08:00",
|
||||
"updated_at": "2026-06-18T00:35:58.3371209+08:00",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[来自 !72d17be4] 你好"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "你好!很高兴见到你!😊\n\n我是DeepSeek,由深度求索公司创造的AI助手。无论你有什么问题、需要帮助,还是只是想聊聊天,我都很乐意陪你一起探索\ufffd..."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "bot_1_1781714169190665700",
|
||||
"bot_id": 1,
|
||||
"bot_node_id": "!1abcead5",
|
||||
"title": "[来自 !72d17be4] 你好",
|
||||
"created_at": "2026-06-18T00:36:09.1906657+08:00",
|
||||
"updated_at": "2026-06-18T00:36:13.5432077+08:00",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[来自 !72d17be4] 你好"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "你好!很高兴见到你。😊\n\n我是DeepSeek,由深度求索公司创造的AI助手。我可以帮你解答问题、处理文档、进行创作、分析数据……几乎任何文字相关\ufffd..."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "bot_1_1781714179192265800",
|
||||
"bot_id": 1,
|
||||
"bot_node_id": "!1abcead5",
|
||||
"title": "[来自 !72d17be4] 111",
|
||||
"created_at": "2026-06-18T00:36:19.1922658+08:00",
|
||||
"updated_at": "2026-06-18T00:36:23.4404859+08:00",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[来自 !72d17be4] 111"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "你好!你发送了“111”,请问有什么需要帮助的吗?如果有具体问题或指令,请告诉我。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"id": "bot_1_1781714224192405100",
|
||||
"bot_id": 1,
|
||||
"bot_node_id": "!1abcead5",
|
||||
"title": "[来自 !72d17be4] 你好",
|
||||
"created_at": "2026-06-18T00:37:04.1924051+08:00",
|
||||
"updated_at": "2026-06-18T00:47:31.9941255+08:00",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[来自 !72d17be4] 你好"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "你好!很高兴见到你!😊\n\n我收到了你的消息,不过看起来前面那个“!72d17be4”像是一个标识符或者系统生成的代码。如果你有什么具体问题或者需��..."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[来自 !72d17be4] 你好"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "你好!再次见到你很高兴!😊\n\n我看到你又一次带着“!72d17be4”这个标识出现了。这个标识似乎一直在跟随你的消息。如果你需要我帮你处理、解析��..."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[来自 !72d17be4] 你好"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "你好!😊\n\n我看到你第三次发来了相同的消息,并且每次前面都带着 **`!72d17be4`** 这个标识。\n\n如果你是在**测试**或者**重复发送**,我理解这可能是��..."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[来自 !72d17be4] 你好"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "你好! 我注意到你连续四次发送了相同格式的消息,前面都带有 `[来自 !72d17be4]` 标识。这可能是系统自动发送测试或者你希望引起我的注意。 无论..."
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user