yao/openapi/chat/session.go
Max 279ae161c6 Enhance chat storage design with HTTP API documentation
- Added comprehensive documentation for the new RESTful HTTP APIs for managing chat sessions and messages, including endpoints for listing, retrieving, updating, and deleting chat sessions.
- Included detailed request and response examples for each endpoint, along with query parameters and permission filtering guidelines.
- Updated the `CHAT_STORAGE_DESIGN.md` to reflect these changes, ensuring clarity on the API's functionality and usage.
2025-12-09 16:03:17 +08:00

544 lines
15 KiB
Go

package chat
import (
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/yao/agent/assistant"
storetypes "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/openapi/oauth/authorized"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
)
// =============================================================================
// Chat Session Handlers
// =============================================================================
// ListChats lists chat sessions with pagination and filtering
// GET /v1/chat/sessions
func ListChats(c *gin.Context) {
// Get chat store
chatStore := assistant.GetChatStore()
if chatStore == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Chat storage not initialized",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Get authorized information
authInfo := authorized.GetInfo(c)
// Build filter from query parameters
filter := buildChatFilter(c, authInfo)
// Call store to list chats
result, err := chatStore.ListChats(filter)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Return result
response.RespondWithSuccess(c, response.StatusOK, gin.H{
"data": result.Data,
"groups": result.Groups,
"page": result.Page,
"pagesize": result.PageSize,
"pagecount": result.PageCount,
"total": result.Total,
})
}
// GetChat retrieves a single chat session by ID
// GET /v1/chat/sessions/:chat_id
func GetChat(c *gin.Context) {
// Get chat store
chatStore := assistant.GetChatStore()
if chatStore == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Chat storage not initialized",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Get chat ID from URL parameter
chatID := c.Param("chat_id")
if chatID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Chat ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Get authorized information
authInfo := authorized.GetInfo(c)
// Check permission
hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, true)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
if !hasPermission {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to access this chat",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Get chat
chat, err := chatStore.GetChat(chatID)
if err != nil {
// Check if it's a "not found" error
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Chat not found",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
response.RespondWithSuccess(c, response.StatusOK, chat)
}
// UpdateChat updates a chat session
// PUT /v1/chat/sessions/:chat_id
func UpdateChat(c *gin.Context) {
// Get chat store
chatStore := assistant.GetChatStore()
if chatStore == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Chat storage not initialized",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Get chat ID from URL parameter
chatID := c.Param("chat_id")
if chatID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Chat ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Parse request body
var req UpdateChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request format: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Get authorized information
authInfo := authorized.GetInfo(c)
// Check permission (write access)
hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, false)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
if !hasPermission {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to update this chat",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Build updates map
updates := make(map[string]interface{})
if req.Title != nil {
updates["title"] = *req.Title
}
if req.Status != nil {
updates["status"] = *req.Status
}
if req.Metadata != nil {
updates["metadata"] = req.Metadata
}
// Add update scope
if authInfo != nil {
updates["__yao_updated_by"] = authInfo.UserID
}
if len(updates) == 0 {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "No fields to update",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Update chat
if err := chatStore.UpdateChat(chatID, updates); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
response.RespondWithSuccess(c, response.StatusOK, gin.H{
"message": "Chat updated successfully",
"chat_id": chatID,
})
}
// DeleteChat deletes a chat session
// DELETE /v1/chat/sessions/:chat_id
func DeleteChat(c *gin.Context) {
// Get chat store
chatStore := assistant.GetChatStore()
if chatStore == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Chat storage not initialized",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Get chat ID from URL parameter
chatID := c.Param("chat_id")
if chatID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Chat ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Get authorized information
authInfo := authorized.GetInfo(c)
// Check permission (write access)
hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, false)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
if !hasPermission {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to delete this chat",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Delete chat
if err := chatStore.DeleteChat(chatID); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
response.RespondWithSuccess(c, response.StatusOK, gin.H{
"message": "Chat deleted successfully",
"chat_id": chatID,
})
}
// =============================================================================
// Message Handlers
// =============================================================================
// GetMessages retrieves messages for a chat session
// GET /v1/chat/sessions/:chat_id/messages
func GetMessages(c *gin.Context) {
// Get chat store
chatStore := assistant.GetChatStore()
if chatStore == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Chat storage not initialized",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Get chat ID from URL parameter
chatID := c.Param("chat_id")
if chatID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Chat ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Get authorized information
authInfo := authorized.GetInfo(c)
// Check permission (read access)
hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, true)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
if !hasPermission {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to access this chat",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Build message filter
filter := buildMessageFilter(c)
// Get messages
messages, err := chatStore.GetMessages(chatID, filter)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
response.RespondWithSuccess(c, response.StatusOK, gin.H{
"chat_id": chatID,
"messages": messages,
"count": len(messages),
})
}
// =============================================================================
// Helper Functions
// =============================================================================
// buildChatFilter builds ChatFilter from query parameters
func buildChatFilter(c *gin.Context, authInfo *oauthtypes.AuthorizedInfo) storetypes.ChatFilter {
filter := storetypes.ChatFilter{}
// Pagination
if pageStr := c.Query("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
filter.Page = p
}
}
if filter.Page == 0 {
filter.Page = 1
}
if pagesizeStr := c.Query("pagesize"); pagesizeStr != "" {
if ps, err := strconv.Atoi(pagesizeStr); err == nil && ps > 0 && ps <= 100 {
filter.PageSize = ps
}
}
if filter.PageSize == 0 {
filter.PageSize = 20
}
// Business filters
filter.AssistantID = strings.TrimSpace(c.Query("assistant_id"))
filter.Status = strings.TrimSpace(c.Query("status"))
filter.Keywords = strings.TrimSpace(c.Query("keywords"))
// Time range filter
if startTimeStr := c.Query("start_time"); startTimeStr != "" {
if t, err := time.Parse(time.RFC3339, startTimeStr); err == nil {
filter.StartTime = &t
}
}
if endTimeStr := c.Query("end_time"); endTimeStr != "" {
if t, err := time.Parse(time.RFC3339, endTimeStr); err == nil {
filter.EndTime = &t
}
}
filter.TimeField = strings.TrimSpace(c.Query("time_field"))
if filter.TimeField == "" {
filter.TimeField = "last_message_at"
}
// Sorting
filter.OrderBy = strings.TrimSpace(c.Query("order_by"))
if filter.OrderBy == "" {
filter.OrderBy = "last_message_at"
}
filter.Order = strings.TrimSpace(c.Query("order"))
if filter.Order == "" {
filter.Order = "desc"
}
// Grouping
filter.GroupBy = strings.TrimSpace(c.Query("group_by"))
// Permission filters based on auth constraints
if authInfo != nil {
// Direct permission filters (AND logic)
if authInfo.Constraints.OwnerOnly {
filter.UserID = authInfo.UserID
}
if authInfo.Constraints.TeamOnly {
filter.TeamID = authInfo.TeamID
}
// For complex permission logic (OR conditions), use QueryFilter
// Example: user can see their own chats OR team shared chats
if authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
// Team member can see: own chats OR team shared chats
filter.QueryFilter = func(qb query.Query) {
qb.Where(func(sub query.Query) {
sub.Where("__yao_created_by", authInfo.UserID).
OrWhere(func(inner query.Query) {
inner.Where("__yao_team_id", authInfo.TeamID).
Where("share", "team")
})
})
}
// Clear direct filters since we're using QueryFilter
filter.UserID = ""
filter.TeamID = ""
}
}
return filter
}
// buildMessageFilter builds MessageFilter from query parameters
func buildMessageFilter(c *gin.Context) storetypes.MessageFilter {
filter := storetypes.MessageFilter{}
// Filter parameters
filter.RequestID = strings.TrimSpace(c.Query("request_id"))
filter.Role = strings.TrimSpace(c.Query("role"))
filter.BlockID = strings.TrimSpace(c.Query("block_id"))
filter.ThreadID = strings.TrimSpace(c.Query("thread_id"))
filter.Type = strings.TrimSpace(c.Query("type"))
// Pagination
if limitStr := c.Query("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
filter.Limit = l
}
}
if filter.Limit == 0 {
filter.Limit = 100
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
filter.Offset = o
}
}
return filter
}
// checkChatPermission checks if the user has permission to access the chat
// readable: true for read access, false for write access
func checkChatPermission(chatStore storetypes.ChatStore, authInfo *oauthtypes.AuthorizedInfo, chatID string, readable bool) (bool, error) {
// No auth info means no constraints (for internal calls)
if authInfo == nil {
return true, nil
}
// No constraints means full access
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
return true, nil
}
// Get chat to check permissions
chat, err := chatStore.GetChat(chatID)
if err != nil {
return false, err
}
// For read access, check if chat is public or shared with team
if readable {
if chat.Public {
return true, nil
}
if chat.Share == "team" && authInfo.Constraints.TeamOnly && chat.TeamID == authInfo.TeamID {
return true, nil
}
}
// Combined Team and Owner permission validation
if authInfo.Constraints.TeamOnly && authInfo.Constraints.OwnerOnly {
if chat.CreatedBy == authInfo.UserID && chat.TeamID == authInfo.TeamID {
return true, nil
}
return false, nil
}
// Owner only permission validation
if authInfo.Constraints.OwnerOnly && chat.CreatedBy == authInfo.UserID {
return true, nil
}
// Team only permission validation
if authInfo.Constraints.TeamOnly && chat.TeamID == authInfo.TeamID {
return true, nil
}
return false, nil
}