- Updated the system configuration to include new role-level defaults for Light, Vision, and Audio connectors. - Refactored the resolveSystemConnector function to prioritize per-agent overrides, improving connector resolution logic. - Enhanced LLMConnector integration across various components to streamline settings retrieval and capabilities management. - Improved error handling and logging for connector-related operations, ensuring better diagnostics and user feedback.
631 lines
18 KiB
Go
631 lines
18 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/llmprovider"
|
|
"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
|
|
}
|
|
|
|
// Build response based on grouping mode
|
|
// When group_by is set, data should be nil to avoid duplication
|
|
resp := gin.H{
|
|
"page": result.Page,
|
|
"pagesize": result.PageSize,
|
|
"pagecount": result.PageCount,
|
|
"total": result.Total,
|
|
}
|
|
|
|
if len(result.Groups) > 0 {
|
|
// Grouped response: only include groups
|
|
resp["groups"] = result.Groups
|
|
} else {
|
|
// Flat response: only include data
|
|
resp["data"] = result.Data
|
|
}
|
|
|
|
response.RespondWithSuccess(c, response.StatusOK, resp)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Get locale from query parameter or Accept-Language header
|
|
locale := getLocale(c)
|
|
|
|
// Collect unique assistant IDs from messages and fetch their info
|
|
assistantIDs := collectAssistantIDs(messages)
|
|
assistants := assistant.GetInfoByIDs(assistantIDs, locale)
|
|
resolveAssistantInfoConnectors(assistants, authInfo)
|
|
|
|
response.RespondWithSuccess(c, response.StatusOK, gin.H{
|
|
"chat_id": chatID,
|
|
"messages": messages,
|
|
"count": len(messages),
|
|
"assistants": assistants,
|
|
})
|
|
}
|
|
|
|
// getLocale extracts locale from request
|
|
// Priority: 1. Query param "locale", 2. Accept-Language header
|
|
func getLocale(c *gin.Context) string {
|
|
// Priority 1: Query parameter
|
|
if locale := c.Query("locale"); locale != "" {
|
|
return strings.ToLower(locale)
|
|
}
|
|
|
|
// Priority 2: Header Accept-Language
|
|
if acceptLang := c.GetHeader("Accept-Language"); acceptLang != "" {
|
|
// Parse Accept-Language header (e.g., "en-US,en;q=0.9,zh;q=0.8")
|
|
// Take the first language
|
|
parts := strings.Split(acceptLang, ",")
|
|
if len(parts) > 0 {
|
|
// Remove quality value if present
|
|
lang := strings.Split(parts[0], ";")[0]
|
|
return strings.ToLower(strings.TrimSpace(lang))
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// collectAssistantIDs extracts unique assistant IDs from messages
|
|
func collectAssistantIDs(messages []*storetypes.Message) []string {
|
|
seen := make(map[string]bool)
|
|
var ids []string
|
|
|
|
for _, msg := range messages {
|
|
if msg.AssistantID != "" && !seen[msg.AssistantID] {
|
|
seen[msg.AssistantID] = true
|
|
ids = append(ids, msg.AssistantID)
|
|
}
|
|
}
|
|
|
|
return ids
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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"))
|
|
filter.ChatIDPrefix = strings.TrimSpace(c.Query("chat_id_prefix"))
|
|
|
|
// 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
|
|
}
|
|
|
|
// resolveAssistantInfoConnectors resolves use:: prefixed connectors in a map of AssistantInfo.
|
|
func resolveAssistantInfoConnectors(infos map[string]*storetypes.AssistantInfo, identity llmprovider.Identity) {
|
|
for _, info := range infos {
|
|
if info == nil || !strings.HasPrefix(info.Connector, "use::") {
|
|
continue
|
|
}
|
|
role := strings.TrimPrefix(info.Connector, "use::")
|
|
if role == "" {
|
|
role = "default"
|
|
}
|
|
raw := info.Connector
|
|
if identity != nil && llmprovider.Global != nil {
|
|
if cid, err := llmprovider.Global.GetRoleBy(role, identity); err == nil && cid != "" {
|
|
info.ConnectorRaw = raw
|
|
info.Connector = cid
|
|
continue
|
|
}
|
|
}
|
|
if llmprovider.Global != nil {
|
|
if cid, err := llmprovider.Global.GetRole(role); err == nil && cid != "" {
|
|
info.ConnectorRaw = raw
|
|
info.Connector = cid
|
|
continue
|
|
}
|
|
}
|
|
info.ConnectorRaw = raw
|
|
}
|
|
}
|