up
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerAdminLLMRoutes(r *gin.RouterGroup, store *store) {
|
||||||
|
group := r.Group("/llm")
|
||||||
|
{
|
||||||
|
group.GET("/messages", handleListLLMMessages(store))
|
||||||
|
group.GET("/messages/:id", handleGetLLMMessage(store))
|
||||||
|
group.PUT("/messages/:id/status", handleUpdateLLMMessageStatus(store))
|
||||||
|
group.DELETE("/messages/:id", handleDeleteLLMMessage(store))
|
||||||
|
group.DELETE("/bots/:bot_id/messages", handleDeleteLLMMessagesByBot(store))
|
||||||
|
group.POST("/messages/cleanup", handleCleanupDeletedLLMMessages(store))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListLLMMessages(store *store) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
opts, ok := parseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var botID uint64
|
||||||
|
if botIDStr := c.Query("bot_id"); botIDStr != "" {
|
||||||
|
id, err := strconv.ParseUint(botIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot_id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
botID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
includeDeleted := c.Query("include_deleted") == "true"
|
||||||
|
|
||||||
|
rows, total, err := store.ListLLMMessages(opts, botID, includeDeleted)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, llmMessageDTO(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"limit": opts.Limit,
|
||||||
|
"offset": opts.Offset,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetLLMMessage(store *store) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := store.GetLLMMessage(id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"item": llmMessageDTO(*record)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证状态值
|
||||||
|
validStatus := map[string]bool{
|
||||||
|
llmMessageStatusPending: true,
|
||||||
|
llmMessageStatusProcessing: true,
|
||||||
|
llmMessageStatusProcessed: true,
|
||||||
|
llmMessageStatusError: true,
|
||||||
|
}
|
||||||
|
if !validStatus[req.Status] {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status value"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.UpdateLLMMessageStatus(id, req.Status, req.Error); err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteLLMMessage(store *store) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.SoftDeleteLLMMessage(id); err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteLLMMessagesByBot(store *store) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
botID, err := strconv.ParseUint(c.Param("bot_id"), 10, 64)
|
||||||
|
if err != nil || botID == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.SoftDeleteLLMMessagesByBot(botID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCleanupDeletedLLMMessages(store *store) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Days int `json:"days"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
req.Days = 7 // 默认清理 7 天前删除的消息
|
||||||
|
}
|
||||||
|
if req.Days <= 0 {
|
||||||
|
req.Days = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
before := time.Now().AddDate(0, 0, -req.Days)
|
||||||
|
count, err := store.CleanupDeletedLLMMessages(before)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted_count": count})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const allowEncryptedForwardingLabel = "Allow encrypted MQTT packets to be forwarded when they cannot be decrypted"
|
const allowEncryptedForwardingLabel = "Allow encrypted MQTT packets to be forwarded when they cannot be decrypted"
|
||||||
|
const llmQueueEnabledLabel = "Enable LLM message queue"
|
||||||
|
const llmIncludeChannelLabel = "Include channel messages in LLM queue"
|
||||||
|
|
||||||
type runtimeSettingsRequest struct {
|
type runtimeSettingsRequest struct {
|
||||||
AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"`
|
AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"`
|
||||||
|
LLMQueueEnabled bool `json:"llm_queue_enabled"`
|
||||||
|
LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *runtimeSettingsCache) {
|
func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *runtimeSettingsCache) {
|
||||||
@@ -32,6 +36,14 @@ func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *r
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if _, err := store.SetBoolRuntimeSetting(runtimeSettingLLMQueueEnabled, req.LLMQueueEnabled, llmQueueEnabledLabel); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := store.SetBoolRuntimeSetting(runtimeSettingLLMQueueIncludeChannel, req.LLMIncludeChannelMessages, llmIncludeChannelLabel); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if settings != nil {
|
if settings != nil {
|
||||||
if err := settings.Reload(store); err != nil {
|
if err := settings.Reload(store); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -48,5 +60,9 @@ func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *r
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runtimeSettingsDTO(settings runtimeSettingsSnapshot) gin.H {
|
func runtimeSettingsDTO(settings runtimeSettingsSnapshot) gin.H {
|
||||||
return gin.H{"allow_encrypted_forwarding": settings.AllowEncryptedForwarding}
|
return gin.H{
|
||||||
|
"allow_encrypted_forwarding": settings.AllowEncryptedForwarding,
|
||||||
|
"llm_queue_enabled": settings.LLMQueueEnabled,
|
||||||
|
"llm_include_channel_messages": settings.LLMIncludeChannel,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,5 +289,25 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m
|
|||||||
return fmt.Errorf("insert bot direct message from %s: %w", peerNodeID, err)
|
return fmt.Errorf("insert bot direct message from %s: %w", peerNodeID, err)
|
||||||
}
|
}
|
||||||
_ = clientInfo // mqtt 元数据已经记录在 content_json 里,这里保留参数以保持队列签名一致
|
_ = clientInfo // mqtt 元数据已经记录在 content_json 里,这里保留参数以保持队列签名一致
|
||||||
|
|
||||||
|
// 同时将消息添加到 LLM 队列
|
||||||
|
longName := nullableString(record["long_name"])
|
||||||
|
shortName := nullableString(record["short_name"])
|
||||||
|
channelID := nullableString(record["channel_id"])
|
||||||
|
_, _ = s.EnqueueLLMMessage(LLMMessageQueueInput{
|
||||||
|
BotID: bot.ID,
|
||||||
|
BotNodeID: bot.NodeID,
|
||||||
|
BotNodeNum: bot.NodeNum,
|
||||||
|
FromNodeID: peerNodeID,
|
||||||
|
FromNodeNum: int64(peerNum),
|
||||||
|
LongName: longName,
|
||||||
|
ShortName: shortName,
|
||||||
|
Text: text,
|
||||||
|
PacketID: int64(packetID),
|
||||||
|
ChannelID: channelID,
|
||||||
|
Topic: topic,
|
||||||
|
ContentJSON: contentPtr,
|
||||||
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,6 +343,44 @@ const (
|
|||||||
botDirectMessageDirectionOutbound = "outbound"
|
botDirectMessageDirectionOutbound = "outbound"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// llmMessageQueueRecord 是 LLM 消息队列,用于暂存机器人收到的消息供 LLM 处理。
|
||||||
|
//
|
||||||
|
// - 每个队列绑定一个 BotID,消息包含节点信息和消息内容
|
||||||
|
// - deleted_at 用于标记软删除,实际保留一段时间供去重
|
||||||
|
// - received_at 是消息接收时间,processed_at 是 LLM 处理完成时间
|
||||||
|
type llmMessageQueueRecord struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
BotID uint64 `gorm:"column:bot_id;not null;index:idx_llm_queue_bot_created,priority:1"`
|
||||||
|
BotNodeID string `gorm:"column:bot_node_id;not null;index"`
|
||||||
|
BotNodeNum int64 `gorm:"column:bot_node_num;not null;index"`
|
||||||
|
FromNodeID string `gorm:"column:from_node_id;not null;index"`
|
||||||
|
FromNodeNum int64 `gorm:"column:from_node_num;not null;index"`
|
||||||
|
LongName *string `gorm:"column:long_name"`
|
||||||
|
ShortName *string `gorm:"column:short_name"`
|
||||||
|
Text string `gorm:"column:text;type:text;not null"`
|
||||||
|
PacketID int64 `gorm:"column:packet_id;not null;index"`
|
||||||
|
ChannelID *string `gorm:"column:channel_id"`
|
||||||
|
Topic string `gorm:"column:topic;not null"`
|
||||||
|
Status string `gorm:"column:status;not null;index"`
|
||||||
|
Error string `gorm:"column:error;type:text"`
|
||||||
|
ReceivedAt time.Time `gorm:"column:received_at;not null;index"`
|
||||||
|
ProcessedAt *time.Time `gorm:"column:processed_at;index"`
|
||||||
|
DeletedAt *time.Time `gorm:"column:deleted_at;index"`
|
||||||
|
ContentJSON *string `gorm:"column:content_json;type:text"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_llm_queue_bot_created,priority:2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (llmMessageQueueRecord) TableName() string {
|
||||||
|
return "llm_message_queue"
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
llmMessageStatusPending = "pending"
|
||||||
|
llmMessageStatusProcessing = "processing"
|
||||||
|
llmMessageStatusProcessed = "processed"
|
||||||
|
llmMessageStatusError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
type nodeInfoRecord struct {
|
type nodeInfoRecord struct {
|
||||||
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
||||||
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
||||||
@@ -550,6 +588,7 @@ func (s *store) migrate() error {
|
|||||||
{label: "bot_nodes", model: &botNodeRecord{}},
|
{label: "bot_nodes", model: &botNodeRecord{}},
|
||||||
{label: "bot_messages", model: &botMessageRecord{}},
|
{label: "bot_messages", model: &botMessageRecord{}},
|
||||||
{label: "bot_direct_messages", model: &botDirectMessageRecord{}},
|
{label: "bot_direct_messages", model: &botDirectMessageRecord{}},
|
||||||
|
{label: "llm_message_queue", model: &llmMessageQueueRecord{}},
|
||||||
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
||||||
{label: "map_report", model: &mapReportRecord{}},
|
{label: "map_report", model: &mapReportRecord{}},
|
||||||
{label: "text_message", model: &textMessageRecord{}},
|
{label: "text_message", model: &textMessageRecord{}},
|
||||||
@@ -571,6 +610,7 @@ func (s *store) migrate() error {
|
|||||||
}{
|
}{
|
||||||
{label: "text_message", model: &textMessageRecord{}, indexes: []string{"idx_text_message_from_num_created_at", "idx_text_message_created_at", "idx_text_message_packet_id"}},
|
{label: "text_message", model: &textMessageRecord{}, indexes: []string{"idx_text_message_from_num_created_at", "idx_text_message_created_at", "idx_text_message_packet_id"}},
|
||||||
{label: "bot_direct_messages", model: &botDirectMessageRecord{}, indexes: []string{"idx_bot_dm_bot_peer", "idx_bot_dm_bot_created_at"}},
|
{label: "bot_direct_messages", model: &botDirectMessageRecord{}, indexes: []string{"idx_bot_dm_bot_peer", "idx_bot_dm_bot_created_at"}},
|
||||||
|
{label: "llm_message_queue", model: &llmMessageQueueRecord{}, indexes: []string{"idx_llm_queue_bot_created"}},
|
||||||
} {
|
} {
|
||||||
if err := createMissingIndexes(migrator, item.model, item.label, item.indexes); err != nil {
|
if err := createMissingIndexes(migrator, item.model, item.label, item.indexes); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ func (q *dbWriteQueue) EnqueueRecord(record map[string]any, clientInfo mqttClien
|
|||||||
}})
|
}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 频道消息同时也写入 LLM 队列(如果启用的话)
|
||||||
|
q.enqueue(dbWriteJob{typeName: "llm_channel_message", from: record["from"], run: func() error {
|
||||||
|
return enqueueChannelMessageToLLM(q.store, record)
|
||||||
|
}})
|
||||||
q.enqueue(dbWriteJob{typeName: "text_message", from: record["from"], run: func() error {
|
q.enqueue(dbWriteJob{typeName: "text_message", from: record["from"], run: func() error {
|
||||||
return q.store.InsertTextMessage(record, clientInfo)
|
return q.store.InsertTextMessage(record, clientInfo)
|
||||||
}})
|
}})
|
||||||
|
|||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LLMMessageQueueInput 是添加 LLM 队列消息的输入
|
||||||
|
type LLMMessageQueueInput struct {
|
||||||
|
BotID uint64 // 0 表示频道消息
|
||||||
|
BotNodeID string // 频道消息可为空
|
||||||
|
BotNodeNum int64 // 频道消息可为 0
|
||||||
|
FromNodeID string
|
||||||
|
FromNodeNum int64
|
||||||
|
LongName *string
|
||||||
|
ShortName *string
|
||||||
|
Text string
|
||||||
|
PacketID int64
|
||||||
|
ChannelID *string
|
||||||
|
Topic string
|
||||||
|
ContentJSON *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnqueueLLMMessage 将消息添加到 LLM 队列
|
||||||
|
func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueRecord, error) {
|
||||||
|
// 检查 LLM 队列是否启用
|
||||||
|
enabled, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueEnabled, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("check llm queue enabled: %w", err)
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
return nil, nil // 静默返回,不报错
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是频道消息,检查是否启用了频道消息入队
|
||||||
|
if input.BotID == 0 {
|
||||||
|
includeChannel, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueIncludeChannel, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("check llm include channel: %w", err)
|
||||||
|
}
|
||||||
|
if !includeChannel {
|
||||||
|
return nil, nil // 频道消息入队未启用,静默返回
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.FromNodeID == "" {
|
||||||
|
return nil, fmt.Errorf("from_node_id is required")
|
||||||
|
}
|
||||||
|
if input.Text == "" {
|
||||||
|
return nil, fmt.Errorf("text is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否存在重复消息
|
||||||
|
var existing llmMessageQueueRecord
|
||||||
|
if input.BotID > 0 {
|
||||||
|
// 机器人私聊:相同 bot_id + packet_id 且未删除
|
||||||
|
err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL", input.BotID, input.PacketID).
|
||||||
|
Take(&existing).Error
|
||||||
|
} else {
|
||||||
|
// 频道消息:相同 from_node_id + channel_id + packet_id 且未删除
|
||||||
|
channelID := ""
|
||||||
|
if input.ChannelID != nil {
|
||||||
|
channelID = *input.ChannelID
|
||||||
|
}
|
||||||
|
err = s.db.Where("bot_id = 0 AND from_node_id = ? AND channel_id = ? AND packet_id = ? AND deleted_at IS NULL", input.FromNodeID, channelID, input.PacketID).
|
||||||
|
Take(&existing).Error
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
// 重复消息,直接返回已存在的
|
||||||
|
return &existing, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("check duplicate llm message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
record := &llmMessageQueueRecord{
|
||||||
|
BotID: input.BotID,
|
||||||
|
BotNodeID: input.BotNodeID,
|
||||||
|
BotNodeNum: input.BotNodeNum,
|
||||||
|
FromNodeID: input.FromNodeID,
|
||||||
|
FromNodeNum: input.FromNodeNum,
|
||||||
|
LongName: input.LongName,
|
||||||
|
ShortName: input.ShortName,
|
||||||
|
Text: input.Text,
|
||||||
|
PacketID: input.PacketID,
|
||||||
|
ChannelID: input.ChannelID,
|
||||||
|
Topic: input.Topic,
|
||||||
|
Status: llmMessageStatusPending,
|
||||||
|
ReceivedAt: now,
|
||||||
|
ContentJSON: input.ContentJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(record).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("enqueue llm message: %w", err)
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLLMMessages 列出 LLM 队列消息
|
||||||
|
func (s *store) ListLLMMessages(opts listOptions, botID uint64, includeDeleted bool) ([]llmMessageQueueRecord, int64, error) {
|
||||||
|
var rows []llmMessageQueueRecord
|
||||||
|
query := s.db.Model(&llmMessageQueueRecord{})
|
||||||
|
|
||||||
|
if botID > 0 {
|
||||||
|
query = query.Where("bot_id = ?", botID)
|
||||||
|
}
|
||||||
|
if !includeDeleted {
|
||||||
|
query = query.Where("deleted_at IS NULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先获取总数
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count llm messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序和分页
|
||||||
|
query = query.Order("created_at DESC")
|
||||||
|
if opts.Limit > 0 {
|
||||||
|
query = query.Limit(opts.Limit)
|
||||||
|
}
|
||||||
|
if opts.Offset > 0 {
|
||||||
|
query = query.Offset(opts.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Find(&rows).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("list llm messages: %w", err)
|
||||||
|
}
|
||||||
|
return rows, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLLMMessage 获取单条 LLM 消息
|
||||||
|
func (s *store) GetLLMMessage(id uint64) (*llmMessageQueueRecord, error) {
|
||||||
|
var record llmMessageQueueRecord
|
||||||
|
if err := s.db.Where("id = ?", id).Take(&record).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("get llm message %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return &record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLLMMessageStatus 更新 LLM 消息状态
|
||||||
|
func (s *store) UpdateLLMMessageStatus(id uint64, status string, errorMsg string) error {
|
||||||
|
updates := map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"error": errorMsg,
|
||||||
|
}
|
||||||
|
if status == llmMessageStatusProcessed {
|
||||||
|
now := time.Now()
|
||||||
|
updates["processed_at"] = &now
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
return fmt.Errorf("update llm message status %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDeleteLLMMessage 软删除 LLM 消息
|
||||||
|
func (s *store) SoftDeleteLLMMessage(id uint64) error {
|
||||||
|
now := time.Now()
|
||||||
|
if err := s.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Update("deleted_at", &now).Error; err != nil {
|
||||||
|
return fmt.Errorf("soft delete llm message %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDeleteLLMMessagesByBot 软删除指定机器人的所有消息
|
||||||
|
func (s *store) SoftDeleteLLMMessagesByBot(botID uint64) error {
|
||||||
|
now := time.Now()
|
||||||
|
if err := s.db.Model(&llmMessageQueueRecord{}).Where("bot_id = ? AND deleted_at IS NULL", botID).Update("deleted_at", &now).Error; err != nil {
|
||||||
|
return fmt.Errorf("soft delete llm messages for bot %d: %w", botID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupDeletedLLMMessages 清理已软删除超过指定时间的消息
|
||||||
|
func (s *store) CleanupDeletedLLMMessages(before time.Time) (int64, error) {
|
||||||
|
result := s.db.Where("deleted_at IS NOT NULL AND deleted_at < ?", before).Delete(&llmMessageQueueRecord{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return 0, fmt.Errorf("cleanup deleted llm messages: %w", result.Error)
|
||||||
|
}
|
||||||
|
return result.RowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// llmMessageDTO 将数据库记录转换为 API 响应格式
|
||||||
|
func llmMessageDTO(row llmMessageQueueRecord) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": row.ID,
|
||||||
|
"bot_id": row.BotID,
|
||||||
|
"bot_node_id": row.BotNodeID,
|
||||||
|
"bot_node_num": row.BotNodeNum,
|
||||||
|
"from_node_id": row.FromNodeID,
|
||||||
|
"from_node_num": row.FromNodeNum,
|
||||||
|
"long_name": row.LongName,
|
||||||
|
"short_name": row.ShortName,
|
||||||
|
"text": row.Text,
|
||||||
|
"packet_id": row.PacketID,
|
||||||
|
"channel_id": row.ChannelID,
|
||||||
|
"topic": row.Topic,
|
||||||
|
"status": row.Status,
|
||||||
|
"error": row.Error,
|
||||||
|
"received_at": row.ReceivedAt,
|
||||||
|
"processed_at": row.ProcessedAt,
|
||||||
|
"deleted_at": row.DeletedAt,
|
||||||
|
"created_at": row.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueChannelMessageToLLM 将频道消息添加到 LLM 队列
|
||||||
|
func enqueueChannelMessageToLLM(s *store, record map[string]any) error {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
text, _ := record["text"].(string)
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fromNodeID, _ := record["from"].(string)
|
||||||
|
if fromNodeID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fromNodeNum, err := int64FromAny(record["from_num"])
|
||||||
|
if err != nil {
|
||||||
|
fromNodeNum = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var packetID int64
|
||||||
|
if p, ok := record["packet_id"].(float64); ok {
|
||||||
|
packetID = int64(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
topic, _ := record["topic"].(string)
|
||||||
|
|
||||||
|
var longName, shortName *string
|
||||||
|
if ln, ok := record["long_name"].(string); ok && ln != "" {
|
||||||
|
longName = &ln
|
||||||
|
}
|
||||||
|
if sn, ok := record["short_name"].(string); ok && sn != "" {
|
||||||
|
shortName = &sn
|
||||||
|
}
|
||||||
|
|
||||||
|
var channelID *string
|
||||||
|
if cid, ok := record["channel_id"].(string); ok && cid != "" {
|
||||||
|
channelID = &cid
|
||||||
|
}
|
||||||
|
|
||||||
|
contentJSON, err := json.Marshal(record)
|
||||||
|
var contentPtr *string
|
||||||
|
if err == nil {
|
||||||
|
s := string(contentJSON)
|
||||||
|
contentPtr = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = s.EnqueueLLMMessage(LLMMessageQueueInput{
|
||||||
|
BotID: 0, // 0 表示频道消息
|
||||||
|
BotNodeID: "",
|
||||||
|
BotNodeNum: 0,
|
||||||
|
FromNodeID: fromNodeID,
|
||||||
|
FromNodeNum: fromNodeNum,
|
||||||
|
LongName: longName,
|
||||||
|
ShortName: shortName,
|
||||||
|
Text: text,
|
||||||
|
PacketID: packetID,
|
||||||
|
ChannelID: channelID,
|
||||||
|
Topic: topic,
|
||||||
|
ContentJSON: contentPtr,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
|
|||||||
import AdminBot from './components/AdminBot.vue'
|
import AdminBot from './components/AdminBot.vue'
|
||||||
import AdminBotDirect from './components/AdminBotDirect.vue'
|
import AdminBotDirect from './components/AdminBotDirect.vue'
|
||||||
import AdminDashboard from './components/AdminDashboard.vue'
|
import AdminDashboard from './components/AdminDashboard.vue'
|
||||||
|
import AdminLLM from './components/AdminLLM.vue'
|
||||||
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||||
import AdminHelpEdit from './components/AdminHelpEdit.vue'
|
import AdminHelpEdit from './components/AdminHelpEdit.vue'
|
||||||
import AdminLogin from './components/AdminLogin.vue'
|
import AdminLogin from './components/AdminLogin.vue'
|
||||||
@@ -29,6 +30,7 @@ const isAdminPage = adminPath.startsWith('/admin')
|
|||||||
const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/'
|
const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/'
|
||||||
const isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/'
|
const isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/'
|
||||||
const isBotDirectAdminPage = adminPath === '/admin/bot/direct' || adminPath === '/admin/bot/direct/'
|
const isBotDirectAdminPage = adminPath === '/admin/bot/direct' || adminPath === '/admin/bot/direct/'
|
||||||
|
const isLLMAdminPage = adminPath === '/admin/llm' || adminPath === '/admin/llm/'
|
||||||
const isSignAdminPage = adminPath === '/admin/sign' || adminPath === '/admin/sign/'
|
const isSignAdminPage = adminPath === '/admin/sign' || adminPath === '/admin/sign/'
|
||||||
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||||
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||||
@@ -550,6 +552,7 @@ onBeforeUnmount(() => {
|
|||||||
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
||||||
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a>
|
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a>
|
||||||
<a href="/admin/bot/direct" :class="{ active: isBotDirectAdminPage }">机器人私聊</a>
|
<a href="/admin/bot/direct" :class="{ active: isBotDirectAdminPage }">机器人私聊</a>
|
||||||
|
<a href="/admin/llm" :class="{ active: isLLMAdminPage }">LLM消息队列</a>
|
||||||
<a href="/admin/sign" :class="{ active: isSignAdminPage }">签到管理</a>
|
<a href="/admin/sign" :class="{ active: isSignAdminPage }">签到管理</a>
|
||||||
<a href="/admin/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
|
<a href="/admin/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
|
||||||
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
||||||
@@ -598,6 +601,7 @@ onBeforeUnmount(() => {
|
|||||||
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
||||||
<AdminBot v-else-if="isBotAdminPage" />
|
<AdminBot v-else-if="isBotAdminPage" />
|
||||||
<AdminBotDirect v-else-if="isBotDirectAdminPage" />
|
<AdminBotDirect v-else-if="isBotDirectAdminPage" />
|
||||||
|
<AdminLLM v-else-if="isLLMAdminPage" />
|
||||||
<AdminSignManagement v-else-if="isSignAdminPage" />
|
<AdminSignManagement v-else-if="isSignAdminPage" />
|
||||||
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
|
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
|
||||||
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import type {
|
|||||||
TextMessage,
|
TextMessage,
|
||||||
BotDirectMessage,
|
BotDirectMessage,
|
||||||
BotDirectConversationsResponse,
|
BotDirectConversationsResponse,
|
||||||
|
LLMMessage,
|
||||||
|
LLMMessageStatus,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -429,3 +431,38 @@ export type { BotDirectConversation } from './types'
|
|||||||
export function sendBotMessage(payload: BotSendMessagePayload): Promise<BotMessageMutationResponse> {
|
export function sendBotMessage(payload: BotSendMessagePayload): Promise<BotMessageMutationResponse> {
|
||||||
return postJSON<BotMessageMutationResponse>('/api/admin/bot/messages', payload)
|
return postJSON<BotMessageMutationResponse>('/api/admin/bot/messages', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LLM 消息队列 API
|
||||||
|
export function getLLMMessages(limit = 100, offset = 0, botId?: number, includeDeleted = false): Promise<ListResponse<LLMMessage>> {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
|
||||||
|
if (botId !== undefined && botId > 0) {
|
||||||
|
params.set('bot_id', String(botId))
|
||||||
|
}
|
||||||
|
if (includeDeleted) {
|
||||||
|
params.set('include_deleted', 'true')
|
||||||
|
}
|
||||||
|
return getJSON<ListResponse<LLMMessage>>(`/api/admin/llm/messages?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLLMMessage(id: number): Promise<{ item: LLMMessage }> {
|
||||||
|
return getJSON<{ item: LLMMessage }>(`/api/admin/llm/messages/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLLMMessageStatus(id: number, status: LLMMessageStatus, error?: string): Promise<{ status: string }> {
|
||||||
|
return putJSON<{ status: string }>(`/api/admin/llm/messages/${id}/status`, { status, error })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteLLMMessage(id: number): Promise<{ status: string }> {
|
||||||
|
return deleteJSON<{ status: string }>(`/api/admin/llm/messages/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteLLMMessagesByBot(botId: number): Promise<{ status: string }> {
|
||||||
|
return deleteJSON<{ status: string }>(`/api/admin/llm/bots/${botId}/messages`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupDeletedLLMMessages(days = 7): Promise<{ status: string; deleted_count: number }> {
|
||||||
|
return postJSON<{ status: string; deleted_count: number }>('/api/admin/llm/messages/cleanup', { days })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静默使用未导出类型,避免 TS6133(未使用的导入)。
|
||||||
|
export type { LLMMessage, LLMMessageStatus } from './types'
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { deleteLLMMessage, deleteLLMMessagesByBot, getAdminRuntimeSettings, getBotNodes, getLLMMessages, updateAdminRuntimeSettings } from '../api'
|
||||||
|
import type { AdminRuntimeSettings, BotNode, LLMMessage, ListResponse } from '../types'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const savingSettings = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const messages = ref<LLMMessage[]>([])
|
||||||
|
const botNodes = ref<BotNode[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const limit = 50
|
||||||
|
const offset = ref(0)
|
||||||
|
const selectedBotId = ref<number | ''>('')
|
||||||
|
const includeDeleted = ref(false)
|
||||||
|
const settings = ref<AdminRuntimeSettings | null>(null)
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
pending: 'background-color: #fff3cd;',
|
||||||
|
processing: 'background-color: #cfe2ff;',
|
||||||
|
processed: 'background-color: #d1e7dd;',
|
||||||
|
error: 'background-color: #f8d7da;',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
pending: '待处理',
|
||||||
|
processing: '处理中',
|
||||||
|
processed: '已处理',
|
||||||
|
error: '错误',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const response = await getAdminRuntimeSettings()
|
||||||
|
settings.value = response.item
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载设置失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleQueueEnabled() {
|
||||||
|
if (!settings.value || savingSettings.value) return
|
||||||
|
savingSettings.value = true
|
||||||
|
try {
|
||||||
|
await updateAdminRuntimeSettings({
|
||||||
|
allow_encrypted_forwarding: settings.value.allow_encrypted_forwarding,
|
||||||
|
llm_queue_enabled: !settings.value.llm_queue_enabled,
|
||||||
|
llm_include_channel_messages: settings.value.llm_include_channel_messages,
|
||||||
|
})
|
||||||
|
settings.value.llm_queue_enabled = !settings.value.llm_queue_enabled
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
savingSettings.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleIncludeChannel() {
|
||||||
|
if (!settings.value || savingSettings.value) return
|
||||||
|
savingSettings.value = true
|
||||||
|
try {
|
||||||
|
await updateAdminRuntimeSettings({
|
||||||
|
allow_encrypted_forwarding: settings.value.allow_encrypted_forwarding,
|
||||||
|
llm_queue_enabled: settings.value.llm_queue_enabled,
|
||||||
|
llm_include_channel_messages: !settings.value.llm_include_channel_messages,
|
||||||
|
})
|
||||||
|
settings.value.llm_include_channel_messages = !settings.value.llm_include_channel_messages
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
savingSettings.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageType(msg: LLMMessage): string {
|
||||||
|
return msg.bot_id === 0 ? '频道' : '私聊'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBotNodes() {
|
||||||
|
try {
|
||||||
|
const response = await getBotNodes(100, 0)
|
||||||
|
botNodes.value = response.items
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载机器人列表失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages(resetOffset = false) {
|
||||||
|
if (resetOffset) {
|
||||||
|
offset.value = 0
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const botId = selectedBotId.value === '' ? undefined : selectedBotId.value
|
||||||
|
const response: ListResponse<LLMMessage> = await getLLMMessages(limit, offset.value, botId, includeDeleted.value)
|
||||||
|
messages.value = response.items
|
||||||
|
total.value = response.total ?? response.items.length
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteMessage(id: number) {
|
||||||
|
if (!confirm('确定要删除这条消息吗?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteLLMMessage(id)
|
||||||
|
await loadMessages()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAllByBot() {
|
||||||
|
const botId = selectedBotId.value === '' ? undefined : selectedBotId.value
|
||||||
|
if (!botId) {
|
||||||
|
alert('请先选择机器人')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!confirm(`确定要删除该机器人的所有队列消息吗?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteLLMMessagesByBot(botId)
|
||||||
|
await loadMessages()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timeStr: string | null): string {
|
||||||
|
if (!timeStr) return '-'
|
||||||
|
const date = new Date(timeStr)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayFrom = computed(() => offset.value + 1)
|
||||||
|
const displayTo = computed(() => Math.min(offset.value + limit, total.value))
|
||||||
|
const hasMore = computed(() => offset.value + limit < total.value)
|
||||||
|
const hasPrev = computed(() => offset.value > 0)
|
||||||
|
|
||||||
|
function goPrev() {
|
||||||
|
if (hasPrev.value) {
|
||||||
|
offset.value = Math.max(0, offset.value - limit)
|
||||||
|
loadMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNext() {
|
||||||
|
if (hasMore.value) {
|
||||||
|
offset.value += limit
|
||||||
|
loadMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSettings()
|
||||||
|
loadBotNodes()
|
||||||
|
loadMessages()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-llm">
|
||||||
|
<h2>LLM 消息队列</h2>
|
||||||
|
|
||||||
|
<div class="admin-llm-toolbar">
|
||||||
|
<div class="admin-llm-filter">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" :checked="settings?.llm_queue_enabled ?? false" @change="toggleQueueEnabled" :disabled="savingSettings" />
|
||||||
|
启用 LLM 消息队列
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-llm-filter">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" :checked="settings?.llm_include_channel_messages ?? false" @change="toggleIncludeChannel" :disabled="savingSettings || !settings?.llm_queue_enabled" />
|
||||||
|
包含频道消息
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-llm-filter">
|
||||||
|
<label>选择机器人:</label>
|
||||||
|
<select v-model="selectedBotId" @change="loadMessages(true)">
|
||||||
|
<option :value="''">全部</option>
|
||||||
|
<option v-for="bot in botNodes" :key="bot.id" :value="bot.id">
|
||||||
|
{{ bot.long_name }} ({{ bot.node_id }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-llm-filter">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="includeDeleted" @change="loadMessages(true)" />
|
||||||
|
包含已删除
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="admin-button admin-button-danger" @click="handleDeleteAllByBot" :disabled="!selectedBotId">
|
||||||
|
删除该机器人所有消息
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="admin-button" @click="() => loadMessages()">刷新</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="admin-llm-stats">
|
||||||
|
共 {{ total }} 条消息,当前显示 {{ displayFrom }} - {{ displayTo }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="admin-loading">加载中...</div>
|
||||||
|
|
||||||
|
<table v-else class="admin-llm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>来自节点</th>
|
||||||
|
<th>频道</th>
|
||||||
|
<th>消息内容</th>
|
||||||
|
<th>接收时间</th>
|
||||||
|
<th>处理时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="msg in messages" :key="msg.id" :style="statusColors[msg.status]">
|
||||||
|
<td>{{ msg.id }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="type-badge" :class="getMessageType(msg) === '频道' ? 'channel' : 'direct'">
|
||||||
|
{{ getMessageType(msg) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge" :style="statusColors[msg.status]">
|
||||||
|
{{ statusLabels[msg.status] || msg.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="node-info">
|
||||||
|
<div class="node-name">{{ msg.long_name || msg.short_name || '-' }}</div>
|
||||||
|
<div class="node-id">{{ msg.from_node_id }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="channel-cell">{{ msg.channel_id || (msg.bot_id === 0 ? '默认频道' : '-') }}</td>
|
||||||
|
<td class="message-text">{{ msg.text }}</td>
|
||||||
|
<td>{{ formatTime(msg.received_at) }}</td>
|
||||||
|
<td>{{ formatTime(msg.processed_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="admin-button admin-button-small admin-button-danger" @click="handleDeleteMessage(msg.id)">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="messages.length === 0">
|
||||||
|
<td colspan="9" class="empty-state">暂无消息</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="admin-llm-pagination">
|
||||||
|
<button class="admin-button admin-button-small" @click="goPrev" :disabled="!hasPrev">上一页</button>
|
||||||
|
<span class="pagination-info">第 {{ Math.floor(offset / limit) + 1 }} 页</span>
|
||||||
|
<button class="admin-button admin-button-small" @click="goNext" :disabled="!hasMore">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-llm {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-llm h2 {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-llm-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-llm-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-llm-filter label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-llm-filter select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-llm-stats {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-loading {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge.channel {
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge.direct {
|
||||||
|
background-color: #fce7f3;
|
||||||
|
color: #be185d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-cell {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #64748b;
|
||||||
|
max-width: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-llm-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-llm-table th,
|
||||||
|
.admin-llm-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-llm-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-llm-table tr:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-id {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
max-width: 400px;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem !important;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-llm-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #dc3545;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f8d7da;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-button-small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-button-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-button-danger:hover:not(:disabled) {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -333,10 +333,14 @@ export interface AdminMqttClient {
|
|||||||
|
|
||||||
export interface AdminRuntimeSettings {
|
export interface AdminRuntimeSettings {
|
||||||
allow_encrypted_forwarding: boolean
|
allow_encrypted_forwarding: boolean
|
||||||
|
llm_queue_enabled: boolean
|
||||||
|
llm_include_channel_messages: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminRuntimeSettingsPayload {
|
export interface AdminRuntimeSettingsPayload {
|
||||||
allow_encrypted_forwarding: boolean
|
allow_encrypted_forwarding: boolean
|
||||||
|
llm_queue_enabled?: boolean
|
||||||
|
llm_include_channel_messages?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminRuntimeSettingsResponse {
|
export interface AdminRuntimeSettingsResponse {
|
||||||
@@ -580,3 +584,32 @@ export interface BotMessageMutationResponse {
|
|||||||
item: BotMessage
|
item: BotMessage
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LLM 消息队列
|
||||||
|
export type LLMMessageStatus = 'pending' | 'processing' | 'processed' | 'error'
|
||||||
|
|
||||||
|
export interface LLMMessage {
|
||||||
|
id: number
|
||||||
|
bot_id: number
|
||||||
|
bot_node_id: string
|
||||||
|
bot_node_num: number
|
||||||
|
from_node_id: string
|
||||||
|
from_node_num: number
|
||||||
|
long_name: string | null
|
||||||
|
short_name: string | null
|
||||||
|
text: string
|
||||||
|
packet_id: number
|
||||||
|
channel_id: string | null
|
||||||
|
topic: string
|
||||||
|
status: LLMMessageStatus
|
||||||
|
error: string
|
||||||
|
received_at: string
|
||||||
|
processed_at: string | null
|
||||||
|
deleted_at: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMMessageStatusPayload {
|
||||||
|
status: LLMMessageStatus
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
runtimeSettingAllowEncryptedForwarding = "mqtt.allow_encrypted_forwarding"
|
runtimeSettingAllowEncryptedForwarding = "mqtt.allow_encrypted_forwarding"
|
||||||
|
runtimeSettingLLMQueueEnabled = "llm.queue_enabled"
|
||||||
|
runtimeSettingLLMQueueIncludeChannel = "llm.include_channel_messages"
|
||||||
runtimeSettingTypeBool = "bool"
|
runtimeSettingTypeBool = "bool"
|
||||||
)
|
)
|
||||||
|
|
||||||
type runtimeSettingsSnapshot struct {
|
type runtimeSettingsSnapshot struct {
|
||||||
AllowEncryptedForwarding bool
|
AllowEncryptedForwarding bool
|
||||||
|
LLMQueueEnabled bool
|
||||||
|
LLMIncludeChannel bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetRuntimeSettings() (runtimeSettingsSnapshot, error) {
|
func (s *store) GetRuntimeSettings() (runtimeSettingsSnapshot, error) {
|
||||||
@@ -25,7 +29,19 @@ func (s *store) GetRuntimeSettings() (runtimeSettingsSnapshot, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return runtimeSettingsSnapshot{}, err
|
return runtimeSettingsSnapshot{}, err
|
||||||
}
|
}
|
||||||
return runtimeSettingsSnapshot{AllowEncryptedForwarding: allowEncrypted}, nil
|
llmQueueEnabled, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueEnabled, true)
|
||||||
|
if err != nil {
|
||||||
|
return runtimeSettingsSnapshot{}, err
|
||||||
|
}
|
||||||
|
llmIncludeChannel, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueIncludeChannel, false)
|
||||||
|
if err != nil {
|
||||||
|
return runtimeSettingsSnapshot{}, err
|
||||||
|
}
|
||||||
|
return runtimeSettingsSnapshot{
|
||||||
|
AllowEncryptedForwarding: allowEncrypted,
|
||||||
|
LLMQueueEnabled: llmQueueEnabled,
|
||||||
|
LLMIncludeChannel: llmIncludeChannel,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, error) {
|
func (s *store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, error) {
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
registerAdminMapSourceRoutes(protected, store)
|
registerAdminMapSourceRoutes(protected, store)
|
||||||
registerAdminHelpRoutes(protected, store)
|
registerAdminHelpRoutes(protected, store)
|
||||||
registerAdminBotRoutes(protected, store, botSender)
|
registerAdminBotRoutes(protected, store, botSender)
|
||||||
|
registerAdminLLMRoutes(protected, store)
|
||||||
protected.GET("/me", func(c *gin.Context) {
|
protected.GET("/me", func(c *gin.Context) {
|
||||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||||
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
|
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
|
||||||
|
|||||||
Reference in New Issue
Block a user