Merge remote-tracking branch 'upstream/main' into main (保留常驻通知与顺延迁移版本)
This commit is contained in:
@@ -22,7 +22,7 @@ func RegisterAgentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
capabilities := service.CloudAgentCapabilitySetInfo()
|
||||
ok(c, gin.H{"version": 2, "permissionModes": []string{"read_only", "request_approval", "auto"}, "contextScopes": []string{"canvas"}, "skills": true, "writeTools": true, "billing": "fixed_request", "maxHistoryPairs": 0, "maxHistoryBytes": 64000, "maxSteps": 0, "tools": service.CloudAgentSupportedToolNames(), "capabilitySetVersion": capabilities.Version, "capabilitySetHash": capabilities.Hash, "nodeTypes": capabilities.Nodes})
|
||||
ok(c, gin.H{"version": 2, "permissionModes": []string{"read_only", "request_approval", "auto"}, "contextScopes": []string{"canvas"}, "skills": true, "writeTools": true, "billing": "fixed_request", "maxHistoryPairs": 10, "maxHistoryBytes": 64000, "maxSteps": 0, "tools": service.CloudAgentSupportedToolNames(), "capabilitySetVersion": capabilities.Version, "capabilitySetHash": capabilities.Hash, "nodeTypes": capabilities.Nodes})
|
||||
})
|
||||
// Profiles are durable preference data, not an authorization surface. The
|
||||
// service validates scope ownership and the compiler injects the effective
|
||||
@@ -130,6 +130,50 @@ func RegisterAgentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.POST("/agent/runs", create)
|
||||
// Each additional message starts a new immutable turn and returns its ID.
|
||||
r.POST("/agent/runs/:id/messages", create)
|
||||
r.POST("/agent/runs/:id/interjections", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "tasks:"+user.ID, policy.Request.TaskCreatePerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
if _, err = svc.CloudAgentRun(user.ID, c.Param("id")); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
MessageID string `json:"messageId"`
|
||||
}
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if req.Text != "" && (!utf8.ValidString(req.Text) || utf8.RuneCountInString(req.Text) > 4000 || strings.ContainsRune(req.Text, 0)) {
|
||||
fail(c, http.StatusBadRequest, errors.New("text 无效"))
|
||||
return
|
||||
}
|
||||
if req.MessageID != "" && (!utf8.ValidString(req.MessageID) || utf8.RuneCountInString(req.MessageID) > 160) {
|
||||
fail(c, http.StatusBadRequest, errors.New("messageId 无效"))
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
fail(c, http.StatusBadRequest, errors.New("请求必须只包含一个 JSON 对象"))
|
||||
return
|
||||
}
|
||||
pending, err := svc.InterjectCloudAgent(user.ID, c.Param("id"), req.MessageID, req.Text)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"accepted": true, "pending": pending})
|
||||
})
|
||||
r.GET("/agent/runs/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
|
||||
236
backend/internal/handler/agent_lesson.go
Normal file
236
backend/internal/handler/agent_lesson.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterAgentMemoryRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.GET("/agent/memories", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
memories, err := svc.UserAgentMemories(user.ID, strings.TrimSpace(c.Query("status")), limit)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"memories": memories})
|
||||
})
|
||||
r.POST("/agent/memories", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req service.AgentMemoryRequest
|
||||
if err := decodeAgentJSON(c, &req, 64<<10); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
view, err := svc.CreateUserAgentMemory(user.ID, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, view)
|
||||
})
|
||||
r.GET("/agent/memories/export", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
bundle, err := svc.ExportUserAgentMemories(user.ID)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, bundle)
|
||||
})
|
||||
r.POST("/agent/memories/import", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var bundle service.AgentMemoryBundle
|
||||
if err := decodeAgentJSON(c, &bundle, 512<<10); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.ImportUserAgentMemories(user.ID, bundle)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, result)
|
||||
})
|
||||
r.GET("/agent/memories/settings", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
view, err := svc.UserAgentMemoryCompact(user.ID)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, view)
|
||||
})
|
||||
r.PATCH("/agent/memories/settings", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req service.AgentMemorySettingRequest
|
||||
if err := decodeAgentJSON(c, &req, 8<<10); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
view, err := svc.UpdateUserAgentMemorySetting(user.ID, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, view)
|
||||
})
|
||||
r.POST("/agent/memories/compact", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req service.AgentMemoryCompactRequest
|
||||
if err := decodeAgentJSON(c, &req, 8<<10); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
view, err := svc.CompactUserAgentMemories(user.ID, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, view)
|
||||
})
|
||||
r.PATCH("/agent/memories/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req service.AgentMemoryRequest
|
||||
if err := decodeAgentJSON(c, &req, 64<<10); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
view, err := svc.UpdateUserAgentMemory(user.ID, c.Param("id"), req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, view)
|
||||
})
|
||||
r.POST("/agent/memories/:id/decide", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Decision string `json:"decision"`
|
||||
}
|
||||
if err := decodeAgentJSON(c, &request, 4<<10); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := svc.DecideUserAgentMemory(user.ID, c.Param("id"), request.Decision); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": c.Param("id"), "decision": request.Decision})
|
||||
})
|
||||
r.DELETE("/agent/memories/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.DeleteUserAgentMemory(user.ID, c.Param("id")); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": c.Param("id")})
|
||||
})
|
||||
}
|
||||
|
||||
func RegisterAgentLessonAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
admin := r.Group("/admin/agent-lessons")
|
||||
|
||||
admin.GET("", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.RequireAdmin(user); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
lessons, err := svc.AdminAgentLessons(
|
||||
strings.TrimSpace(c.Query("status")),
|
||||
strings.TrimSpace(c.Query("userId")),
|
||||
strings.TrimSpace(c.Query("keyword")),
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"lessons": lessons})
|
||||
})
|
||||
|
||||
admin.DELETE("/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.RequireAdmin(user); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.AdminDeleteAgentLesson(c.Param("id")); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": c.Param("id")})
|
||||
})
|
||||
}
|
||||
|
||||
func decodeAgentJSON(c *gin.Context, dest any, maxBytes int64) error {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return errors.New("请求必须只包含一个 JSON 对象")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -15,8 +15,10 @@ func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) {
|
||||
RegisterAppearanceRoutes(api, svc)
|
||||
RegisterFeatureAvailabilityRoutes(api, svc)
|
||||
RegisterAgentRoutes(api, svc)
|
||||
RegisterAgentMemoryRoutes(api, svc)
|
||||
RegisterResponseInterceptionRoutes(api, svc)
|
||||
RegisterAdminRoutes(api, svc)
|
||||
RegisterAgentLessonAdminRoutes(api, svc)
|
||||
RegisterAdminAnalyticsRoutes(api, svc)
|
||||
RegisterAdminStorageRoutes(api, svc)
|
||||
RegisterAdminUpdateRoutes(api, svc)
|
||||
|
||||
@@ -40,7 +40,7 @@ paths:
|
||||
summary: 读取云端 Agent 能力
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data 包含 version、permissionModes、contextScopes、skills、writeTools、billing、maxHistoryPairs、maxSteps、maxHistoryBytes、capabilitySetVersion、capabilitySetHash、nodeTypes、tools;maxHistoryPairs 和 maxSteps 为 0 表示无固定轮数上限,历史仍受 maxHistoryBytes 限制
|
||||
description: 信封 data 包含 version、permissionModes、contextScopes、skills、writeTools、billing、maxHistoryPairs、maxSteps、maxHistoryBytes、capabilitySetVersion、capabilitySetHash、nodeTypes、tools;maxHistoryPairs 为跨轮保留的用户对话轮数,maxSteps 为 0 表示无固定模型调用上限,历史仍受 maxHistoryBytes 限制
|
||||
/agent/profile:
|
||||
get:
|
||||
tags: [agent]
|
||||
@@ -78,6 +78,204 @@ paths:
|
||||
description: 返回更新后的生效偏好视图
|
||||
"409":
|
||||
description: 偏好 revision 已变化,须重新读取后保存
|
||||
/agent/memories:
|
||||
get:
|
||||
tags: [agent]
|
||||
summary: 列出当前用户的 Agent 记忆
|
||||
parameters:
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
enum: [pending, approved, rejected]
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 500
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.memories
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
post:
|
||||
tags: [agent]
|
||||
summary: 手动添加一条个人记忆(直接批准)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [topic, category, situation]
|
||||
properties:
|
||||
topic: { type: string }
|
||||
category: { type: string }
|
||||
situation: { type: string }
|
||||
lesson: { type: string }
|
||||
source: { type: string }
|
||||
steps:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
tool: { type: string }
|
||||
action: { type: string }
|
||||
note: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"400":
|
||||
$ref: "#/components/responses/Error"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
/agent/memories/export:
|
||||
get:
|
||||
tags: [agent]
|
||||
summary: 导出当前用户的 Agent 记忆
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data 为 version/kind/exportedAt/memories
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
/agent/memories/import:
|
||||
post:
|
||||
tags: [agent]
|
||||
summary: 导入 Agent 记忆 JSON
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version: { type: integer }
|
||||
kind: { type: string }
|
||||
memories: { type: array }
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data 含 imported、merged、skipped
|
||||
"400":
|
||||
$ref: "#/components/responses/Error"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
/agent/memories/settings:
|
||||
get:
|
||||
tags: [agent]
|
||||
summary: 读取当前用户的记忆压缩设置与最近一次压缩状态
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data 含 compactInterval、模型字段、lastStatus、summary
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
patch:
|
||||
tags: [agent]
|
||||
summary: 更新记忆压缩周期和文本模型
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
compactInterval:
|
||||
type: string
|
||||
enum: [off, daily, weekly, monthly]
|
||||
logicalModelId: { type: string }
|
||||
channelId: { type: string }
|
||||
channelModelKey: { type: string }
|
||||
model: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"400":
|
||||
$ref: "#/components/responses/Error"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
/agent/memories/compact:
|
||||
post:
|
||||
tags: [agent]
|
||||
summary: 提交一次个人记忆压缩任务(走用户文本模型计费)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
logicalModelId: { type: string }
|
||||
channelId: { type: string }
|
||||
channelModelKey: { type: string }
|
||||
model: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data 为压缩设置视图,lastStatus 为 queued
|
||||
"400":
|
||||
$ref: "#/components/responses/Error"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
/agent/memories/{id}:
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
patch:
|
||||
tags: [agent]
|
||||
summary: 更新一条属于当前用户的记忆
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"404":
|
||||
$ref: "#/components/responses/Error"
|
||||
delete:
|
||||
tags: [agent]
|
||||
summary: 删除一条属于当前用户的记忆
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"404":
|
||||
$ref: "#/components/responses/Error"
|
||||
/agent/memories/{id}/decide:
|
||||
post:
|
||||
tags: [agent]
|
||||
summary: 批准或拒绝一条属于当前用户的待审记忆
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [decision]
|
||||
properties:
|
||||
decision:
|
||||
type: string
|
||||
enum: [approve, reject]
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"400":
|
||||
$ref: "#/components/responses/Error"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"404":
|
||||
$ref: "#/components/responses/Error"
|
||||
/agent/runs:
|
||||
post:
|
||||
tags: [agent]
|
||||
@@ -123,6 +321,33 @@ paths:
|
||||
description: 信封 data.run(新的 ID)
|
||||
"409":
|
||||
description: 父轮仍在执行或幂等键冲突
|
||||
/agent/runs/{id}/interjections:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/AgentRunID"
|
||||
post:
|
||||
tags: [agent]
|
||||
summary: 向正在运行的 Agent 轮次插入一句话
|
||||
description: 不新建轮次、不产生新的扣费身份。插话排进当前运行,在下一次模型调用前送达;运行已结束返回 409。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [text, messageId]
|
||||
properties:
|
||||
text:
|
||||
type: string
|
||||
maxLength: 4000
|
||||
messageId:
|
||||
type: string
|
||||
maxLength: 160
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.accepted 与 data.pending
|
||||
"409":
|
||||
description: 本轮已结束,应改为新一轮发送
|
||||
/agent/runs/{id}/cancel:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/AgentRunID"
|
||||
@@ -267,6 +492,55 @@ paths:
|
||||
$ref: "#/components/responses/Error"
|
||||
"404":
|
||||
$ref: "#/components/responses/Error"
|
||||
/admin/agent-lessons:
|
||||
get:
|
||||
tags: [admin]
|
||||
summary: 按用户筛选查看个人 Agent 记忆
|
||||
parameters:
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
enum: [pending, approved, rejected]
|
||||
- in: query
|
||||
name: userId
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: keyword
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.lessons 含解析后的 steps、authorUserId、authorUsername、authorDisplayName
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"403":
|
||||
$ref: "#/components/responses/Error"
|
||||
/admin/agent-lessons/{id}:
|
||||
delete:
|
||||
tags: [admin]
|
||||
summary: 删除一条用户的 Agent 记忆
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/Envelope"
|
||||
"401":
|
||||
$ref: "#/components/responses/Error"
|
||||
"403":
|
||||
$ref: "#/components/responses/Error"
|
||||
"404":
|
||||
$ref: "#/components/responses/Error"
|
||||
/auth/session:
|
||||
get:
|
||||
tags: [auth]
|
||||
|
||||
Reference in New Issue
Block a user