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 llmQueueEnabledLabel = "Enable LLM message queue"
|
||||
const llmIncludeChannelLabel = "Include channel messages in LLM queue"
|
||||
|
||||
type runtimeSettingsRequest struct {
|
||||
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) {
|
||||
@@ -32,6 +36,14 @@ func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *r
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
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 err := settings.Reload(store); err != nil {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
_ = 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
|
||||
}
|
||||
|
||||
@@ -343,6 +343,44 @@ const (
|
||||
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 {
|
||||
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
||||
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_messages", model: &botMessageRecord{}},
|
||||
{label: "bot_direct_messages", model: &botDirectMessageRecord{}},
|
||||
{label: "llm_message_queue", model: &llmMessageQueueRecord{}},
|
||||
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
||||
{label: "map_report", model: &mapReportRecord{}},
|
||||
{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: "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 {
|
||||
return err
|
||||
|
||||
@@ -51,6 +51,10 @@ func (q *dbWriteQueue) EnqueueRecord(record map[string]any, clientInfo mqttClien
|
||||
}})
|
||||
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 {
|
||||
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 AdminBotDirect from './components/AdminBotDirect.vue'
|
||||
import AdminDashboard from './components/AdminDashboard.vue'
|
||||
import AdminLLM from './components/AdminLLM.vue'
|
||||
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||
import AdminHelpEdit from './components/AdminHelpEdit.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 isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/'
|
||||
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 detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||
@@ -550,6 +552,7 @@ onBeforeUnmount(() => {
|
||||
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
||||
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</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/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
|
||||
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
||||
@@ -598,6 +601,7 @@ onBeforeUnmount(() => {
|
||||
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
||||
<AdminBot v-else-if="isBotAdminPage" />
|
||||
<AdminBotDirect v-else-if="isBotDirectAdminPage" />
|
||||
<AdminLLM v-else-if="isLLMAdminPage" />
|
||||
<AdminSignManagement v-else-if="isSignAdminPage" />
|
||||
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
|
||||
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
||||
|
||||
@@ -47,6 +47,8 @@ import type {
|
||||
TextMessage,
|
||||
BotDirectMessage,
|
||||
BotDirectConversationsResponse,
|
||||
LLMMessage,
|
||||
LLMMessageStatus,
|
||||
} from './types'
|
||||
|
||||
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> {
|
||||
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 {
|
||||
allow_encrypted_forwarding: boolean
|
||||
llm_queue_enabled: boolean
|
||||
llm_include_channel_messages: boolean
|
||||
}
|
||||
|
||||
export interface AdminRuntimeSettingsPayload {
|
||||
allow_encrypted_forwarding: boolean
|
||||
llm_queue_enabled?: boolean
|
||||
llm_include_channel_messages?: boolean
|
||||
}
|
||||
|
||||
export interface AdminRuntimeSettingsResponse {
|
||||
@@ -580,3 +584,32 @@ export interface BotMessageMutationResponse {
|
||||
item: BotMessage
|
||||
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 (
|
||||
runtimeSettingAllowEncryptedForwarding = "mqtt.allow_encrypted_forwarding"
|
||||
runtimeSettingLLMQueueEnabled = "llm.queue_enabled"
|
||||
runtimeSettingLLMQueueIncludeChannel = "llm.include_channel_messages"
|
||||
runtimeSettingTypeBool = "bool"
|
||||
)
|
||||
|
||||
type runtimeSettingsSnapshot struct {
|
||||
AllowEncryptedForwarding bool
|
||||
LLMQueueEnabled bool
|
||||
LLMIncludeChannel bool
|
||||
}
|
||||
|
||||
func (s *store) GetRuntimeSettings() (runtimeSettingsSnapshot, error) {
|
||||
@@ -25,7 +29,19 @@ func (s *store) GetRuntimeSettings() (runtimeSettingsSnapshot, error) {
|
||||
if err != nil {
|
||||
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) {
|
||||
|
||||
@@ -213,6 +213,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
registerAdminMapSourceRoutes(protected, store)
|
||||
registerAdminHelpRoutes(protected, store)
|
||||
registerAdminBotRoutes(protected, store, botSender)
|
||||
registerAdminLLMRoutes(protected, store)
|
||||
protected.GET("/me", func(c *gin.Context) {
|
||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
|
||||
|
||||
Reference in New Issue
Block a user