diff --git a/openapi/chat/session.go b/openapi/chat/session.go index 8913ff47..80d1481e 100644 --- a/openapi/chat/session.go +++ b/openapi/chat/session.go @@ -101,6 +101,10 @@ func GetChat(c *gin.Context) { // Check permission hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, true) if err != nil { + if isNotFoundError(err) { + respondNotFound(c, chatID) + return + } errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: err.Error(), @@ -121,16 +125,10 @@ func GetChat(c *gin.Context) { // 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) + if isNotFoundError(err) { + respondNotFound(c, chatID) return } - errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: err.Error(), @@ -184,6 +182,10 @@ func UpdateChat(c *gin.Context) { // Check permission (write access) hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, false) if err != nil { + if isNotFoundError(err) { + respondNotFound(c, chatID) + return + } errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: err.Error(), @@ -229,6 +231,10 @@ func UpdateChat(c *gin.Context) { // Update chat if err := chatStore.UpdateChat(chatID, updates); err != nil { + if isNotFoundError(err) { + respondNotFound(c, chatID) + return + } errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: err.Error(), @@ -274,6 +280,10 @@ func DeleteChat(c *gin.Context) { // Check permission (write access) hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, false) if err != nil { + if isNotFoundError(err) { + respondNotFound(c, chatID) + return + } errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: err.Error(), @@ -293,6 +303,10 @@ func DeleteChat(c *gin.Context) { // Delete chat if err := chatStore.DeleteChat(chatID); err != nil { + if isNotFoundError(err) { + respondNotFound(c, chatID) + return + } errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: err.Error(), @@ -342,6 +356,10 @@ func GetMessages(c *gin.Context) { // Check permission (read access) hasPermission, err := checkChatPermission(chatStore, authInfo, chatID, true) if err != nil { + if isNotFoundError(err) { + respondNotFound(c, chatID) + return + } errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: err.Error(), @@ -365,6 +383,10 @@ func GetMessages(c *gin.Context) { // Get messages messages, err := chatStore.GetMessages(chatID, filter) if err != nil { + if isNotFoundError(err) { + respondNotFound(c, chatID) + return + } errorResp := &response.ErrorResponse{ Code: response.ErrServerError.Code, ErrorDescription: err.Error(), @@ -551,6 +573,18 @@ func buildMessageFilter(c *gin.Context) storetypes.MessageFilter { return filter } +func isNotFoundError(err error) bool { + return err != nil && strings.Contains(err.Error(), "not found") +} + +func respondNotFound(c *gin.Context, chatID string) { + errorResp := &response.ErrorResponse{ + Code: "resource_not_found", + ErrorDescription: "Chat " + chatID + " not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) +} + // 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) { diff --git a/openapi/setting/preference.go b/openapi/setting/preference.go new file mode 100644 index 00000000..587a7600 --- /dev/null +++ b/openapi/setting/preference.go @@ -0,0 +1,92 @@ +package setting + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/openapi/oauth/authorized" + oauthTypes "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/setting" +) + +const preferenceNS = "preference" + +func preferenceScope(info *oauthTypes.AuthorizedInfo) setting.ScopeID { + return setting.ScopeID{Scope: setting.ScopeUser, UserID: info.UserID} +} + +// handlePreferenceGet returns the current user's preference. +// GET /setting/preference +func handlePreferenceGet(c *gin.Context) { + info := authorized.GetInfo(c) + + if setting.Global == nil { + response.RespondWithSuccess(c, http.StatusOK, PreferenceData{}) + return + } + + merged, _ := setting.Global.GetMerged(info.UserID, info.TeamID, preferenceNS) + data := preferenceFromMap(merged) + response.RespondWithSuccess(c, http.StatusOK, data) +} + +// handlePreferenceUpdate partially updates the current user's preference. +// PUT /setting/preference +func handlePreferenceUpdate(c *gin.Context) { + info := authorized.GetInfo(c) + + var body PreferenceData + if err := c.ShouldBindJSON(&body); err != nil { + respondError(c, http.StatusBadRequest, "invalid request body") + return + } + + if setting.Global == nil { + respondError(c, http.StatusInternalServerError, "setting registry not initialized") + return + } + + scope := preferenceScope(info) + existing, _ := setting.Global.Get(scope, preferenceNS) + + m := make(map[string]interface{}) + for k, v := range existing { + m[k] = v + } + + // Marshal the body to a map so only non-nil fields are included + bodyBytes, _ := json.Marshal(body) + var bodyMap map[string]interface{} + json.Unmarshal(bodyBytes, &bodyMap) + for k, v := range bodyMap { + m[k] = v + } + + if _, err := setting.Global.Set(scope, preferenceNS, m); err != nil { + respondError(c, http.StatusInternalServerError, err.Error()) + return + } + + merged, _ := setting.Global.GetMerged(info.UserID, info.TeamID, preferenceNS) + result := preferenceFromMap(merged) + response.RespondWithSuccess(c, http.StatusOK, result) +} + +func preferenceFromMap(m map[string]interface{}) PreferenceData { + data := PreferenceData{} + if m == nil { + return data + } + if v, ok := m["email_notification"].(bool); ok { + data.EmailNotification = &v + } + if v, ok := m["banner_dismissed"].(bool); ok { + data.BannerDismissed = &v + } + if v, ok := m["onboarding_completed"].(bool); ok { + data.OnboardingCompleted = &v + } + return data +} diff --git a/openapi/setting/setting.go b/openapi/setting/setting.go index 5a754418..0e1458a1 100644 --- a/openapi/setting/setting.go +++ b/openapi/setting/setting.go @@ -74,6 +74,13 @@ func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) { sb.POST("/nodes/:nodeId/images/pull-all", handleSandboxPullAll) sb.DELETE("/nodes/:nodeId/images/:imageId", handleSandboxImageDelete) sb.POST("/nodes/:nodeId/check-docker", handleSandboxCheckDocker) + + group.GET("/setup-status", handleSetupStatus) + group.GET("/setup-status/assistant/:id", handleAssistantSetupStatus) + + pref := group.Group("/preference") + pref.GET("", handlePreferenceGet) + pref.PUT("", handlePreferenceUpdate) } // requireOwner checks that the current user is the team owner. diff --git a/openapi/setting/setup_status.go b/openapi/setting/setup_status.go new file mode 100644 index 00000000..f481f74c --- /dev/null +++ b/openapi/setting/setup_status.go @@ -0,0 +1,608 @@ +package setting + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/agent/assistant" + "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" + "github.com/yaoapp/yao/setting" + "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/registry" +) + +// handleSetupStatus aggregates all system-level configuration checkpoints. +// GET /setting/setup-status +func handleSetupStatus(c *gin.Context) { + info := authorized.GetInfo(c) + locale := strings.ToLower(c.DefaultQuery("locale", "en-us")) + isCN := strings.HasPrefix(locale, "zh") + + checkpoints := make(map[string]Checkpoint, 6) + + checkpoints["llm_default"] = checkLLMDefault(info, isCN) + checkpoints["llm_vision"] = checkLLMVision(info, isCN) + checkpoints["sandbox_node"] = checkSandboxNode(info, isCN) + checkpoints["sandbox_image"] = checkSandboxImage(info, locale, isCN) + checkpoints["search"] = checkSearch(info, isCN) + checkpoints["smtp"] = checkSMTP(info, isCN) + + completed := true + for _, cp := range checkpoints { + if cp.Required && cp.Status == "fail" { + completed = false + break + } + } + + bannerDismissed := false + onboardingCompleted := false + if setting.Global != nil { + prefs, _ := setting.Global.GetMerged(info.UserID, info.TeamID, preferenceNS) + if prefs != nil { + if v, ok := prefs["banner_dismissed"].(bool); ok { + bannerDismissed = v + } + if v, ok := prefs["onboarding_completed"].(bool); ok { + onboardingCompleted = v + } + } + } + + response.RespondWithSuccess(c, http.StatusOK, SetupStatus{ + Completed: completed, + Checkpoints: checkpoints, + OnboardingCompleted: onboardingCompleted, + BannerDismissed: bannerDismissed, + }) +} + +// handleAssistantSetupStatus checks configuration readiness for a specific assistant. +// GET /setting/setup-status/assistant/:id +func handleAssistantSetupStatus(c *gin.Context) { + id := c.Param("id") + info := authorized.GetInfo(c) + locale := strings.ToLower(c.DefaultQuery("locale", "en-us")) + isCN := strings.HasPrefix(locale, "zh") + + cache := assistant.GetCache() + var ast *assistant.Assistant + if cache != nil { + ast, _ = cache.Get(id) + } + if ast == nil { + var err error + ast, err = assistant.Get(id) + if err != nil || ast == nil { + respondError(c, http.StatusNotFound, fmt.Sprintf("assistant %q not found", id)) + return + } + } + + checkpoints := make(map[string]Checkpoint) + allReady := true + + // connector check + cp := checkAssistantConnector(ast, info, isCN) + checkpoints["connector"] = cp + if cp.Status == "fail" { + allReady = false + } + + // sandbox check (only if V2 sandbox configured) + if ast.HasSandboxV2() { + cp := checkAssistantSandbox(ast, info, locale, isCN) + checkpoints["sandbox_ready"] = cp + if cp.Status == "fail" { + allReady = false + } + } + + // search check (only if uses.search is configured and not disabled) + if ast.Uses != nil && ast.Uses.Search != "" && ast.Uses.Search != "disabled" { + cp := checkAssistantSearch(ast, info, isCN) + checkpoints["search"] = cp + if cp.Status == "fail" { + allReady = false + } + } + + name := ast.GetName(locale) + if name == "" { + name = ast.ID + } + + response.RespondWithSuccess(c, http.StatusOK, AssistantSetupStatus{ + AssistantID: id, + AssistantName: name, + Ready: allReady, + Checkpoints: checkpoints, + }) +} + +// --------------------------------------------------------------------------- +// System-level checkpoint helpers +// --------------------------------------------------------------------------- + +// parseRoleTarget extracts provider key and model ID from a role value. +// Supports both map format {"provider":"x","model":"y"} and legacy string "provider::model". +func parseRoleTarget(val interface{}) (providerKey, modelID string) { + switch v := val.(type) { + case map[string]interface{}: + providerKey, _ = v["provider"].(string) + modelID, _ = v["model"].(string) + case string: + if v == "" { + return + } + parts := strings.SplitN(v, "::", 2) + providerKey = parts[0] + if len(parts) == 2 { + modelID = parts[1] + } + } + return +} + +func checkLLMDefault(info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint { + cp := Checkpoint{ + Required: true, + Label: "Default Model", + Path: "/settings/models", + Status: "fail", + } + if isCN { + cp.Label = "默认模型" + } + + if setting.Global == nil || llmprovider.Global == nil { + return cp + } + + roles, _ := setting.Global.GetMerged(info.UserID, info.TeamID, llmprovider.RolesNamespace) + if roles == nil { + return cp + } + + providerKey, _ := parseRoleTarget(roles["default"]) + if providerKey == "" { + return cp + } + + p, err := llmprovider.Global.Get(providerKey) + if err != nil || p == nil || !p.Enabled { + return cp + } + + cp.Status = "pass" + return cp +} + +func checkLLMVision(info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint { + cp := Checkpoint{ + Required: false, + Label: "Vision Model", + Path: "/settings/models", + Status: "fail", + } + if isCN { + cp.Label = "视觉模型" + } + + if setting.Global == nil || llmprovider.Global == nil { + return cp + } + + roles, _ := setting.Global.GetMerged(info.UserID, info.TeamID, llmprovider.RolesNamespace) + if roles == nil { + return cp + } + + // Check dedicated vision role first + if providerKey, modelID := parseRoleTarget(roles["vision"]); providerKey != "" { + if p, err := llmprovider.Global.Get(providerKey); err == nil && p != nil && p.Enabled { + for _, m := range p.Models { + if m.Enabled && hasCapability(m.Capabilities, "vision") { + if modelID == "" || m.ID == modelID { + cp.Status = "pass" + return cp + } + } + } + } + } + + // Fallback: check if default role has vision capability + if providerKey, _ := parseRoleTarget(roles["default"]); providerKey != "" { + if p, err := llmprovider.Global.Get(providerKey); err == nil && p != nil && p.Enabled { + for _, m := range p.Models { + if m.Enabled && hasCapability(m.Capabilities, "vision") { + cp.Status = "pass" + return cp + } + } + } + } + + return cp +} + +func checkSandboxNode(info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint { + cp := Checkpoint{ + Required: true, + Label: "Sandbox Node", + Path: "/settings/sandbox", + Status: "fail", + } + if isCN { + cp.Label = "沙箱节点" + } + + reg := registry.Global() + if reg == nil { + return cp + } + + for _, snap := range reg.List() { + if snap.Mode != "local" && !sandboxNodeOwnedBy(&snap, info) { + continue + } + if snap.Status == "online" && snap.Capabilities.Docker { + cp.Status = "pass" + return cp + } + } + + return cp +} + +func checkSandboxImage(info *oauthTypes.AuthorizedInfo, locale string, isCN bool) Checkpoint { + cp := Checkpoint{ + Required: true, + Label: "Sandbox Images", + Path: "/settings/sandbox", + Status: "fail", + } + if isCN { + cp.Label = "沙箱镜像" + } + + needed := collectAssistantImages(locale) + if len(needed) == 0 { + cp.Status = "pass" + if isCN { + cp.Detail = "无需镜像" + } else { + cp.Detail = "No images needed" + } + return cp + } + + reg := registry.Global() + if reg == nil { + cp.Detail = fmt.Sprintf("0/%d", len(needed)) + return cp + } + + downloaded := 0 + for _, snap := range reg.List() { + if snap.Mode != "local" && !sandboxNodeOwnedBy(&snap, info) { + continue + } + if snap.Status != "online" || !snap.Capabilities.Docker { + continue + } + + res, ok := tai.GetResources(snap.TaiID) + if !ok || res.Image == nil { + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + images, err := res.Image.List(ctx) + cancel() + if err != nil { + continue + } + + tagIndex := make(map[string]bool) + for _, img := range images { + for _, tag := range img.Tags { + tagIndex[tag] = true + } + } + + for imageRef := range needed { + if tagIndex[imageRef] { + downloaded++ + } + } + break // only check the first usable node + } + + if isCN { + cp.Detail = fmt.Sprintf("%d/%d 镜像已下载", downloaded, len(needed)) + } else { + cp.Detail = fmt.Sprintf("%d/%d images downloaded", downloaded, len(needed)) + } + if downloaded > 0 { + cp.Status = "pass" + } + return cp +} + +func checkSearch(info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint { + cp := Checkpoint{ + Required: false, + Label: "Search Provider", + Path: "/settings/search", + Status: "fail", + } + if isCN { + cp.Label = "搜索服务" + } + + if setting.Global == nil { + return cp + } + + for _, preset := range searchPresets { + if preset.IsCloud { + saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, cloudNS) + if saved != nil { + if v, ok := saved["status"].(string); ok && v == "connected" { + cp.Status = "pass" + return cp + } + } + } else { + saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, searchProviderNS(preset.Key)) + if saved != nil { + if v, ok := saved["status"].(string); ok && v == "connected" { + cp.Status = "pass" + return cp + } + } + } + } + + return cp +} + +func checkSMTP(info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint { + cp := Checkpoint{ + Required: false, + Label: "SMTP Email", + Path: "/settings/smtp", + Status: "fail", + } + if isCN { + cp.Label = "邮件服务" + } + + if setting.Global == nil { + return cp + } + + saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, smtpNS) + if saved == nil { + return cp + } + + status, _ := saved["status"].(string) + if status == "connected" { + if enabled, ok := saved["enabled"].(bool); ok && !enabled { + return cp + } + cp.Status = "pass" + } + + return cp +} + +// --------------------------------------------------------------------------- +// Assistant-level checkpoint helpers +// --------------------------------------------------------------------------- + +func checkAssistantConnector(ast *assistant.Assistant, info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint { + cp := Checkpoint{ + Required: true, + Label: "Connector", + Path: "/settings/models", + Status: "fail", + } + if isCN { + cp.Label = "模型连接" + } + + connID := ast.Connector + if connID == "" { + connID = "default" + } + + // Role-based connector: "use::vision", "use::heavy", etc. + if strings.HasPrefix(connID, "use::") { + roleName := strings.TrimPrefix(connID, "use::") + if setting.Global == nil { + return cp + } + roles, _ := setting.Global.GetMerged(info.UserID, info.TeamID, llmprovider.RolesNamespace) + if roles == nil { + return cp + } + pk, mid := parseRoleTarget(roles[roleName]) + if pk == "" { + return cp + } + connID = pk + if mid != "" { + connID = pk + "::" + mid + } + } + + // "default" means use the default role + if connID == "default" { + if setting.Global == nil { + return cp + } + roles, _ := setting.Global.GetMerged(info.UserID, info.TeamID, llmprovider.RolesNamespace) + if roles == nil { + return cp + } + pk, mid := parseRoleTarget(roles["default"]) + if pk == "" { + return cp + } + connID = pk + if mid != "" { + connID = pk + "::" + mid + } + } + + parts := strings.SplitN(connID, "::", 2) + if llmprovider.Global == nil { + return cp + } + p, err := llmprovider.Global.Get(parts[0]) + if err != nil || p == nil || !p.Enabled { + return cp + } + + cp.Status = "pass" + return cp +} + +func checkAssistantSandbox(ast *assistant.Assistant, info *oauthTypes.AuthorizedInfo, locale string, isCN bool) Checkpoint { + cp := Checkpoint{ + Required: true, + Label: "Sandbox Ready", + Path: "/settings/sandbox", + Status: "fail", + } + if isCN { + cp.Label = "沙箱就绪" + } + + imageRef := "" + if ast.SandboxV2 != nil && ast.SandboxV2.Computer.Image != "" { + imageRef = ast.SandboxV2.Computer.Image + } + if imageRef == "" { + cp.Status = "pass" + return cp + } + + reg := registry.Global() + if reg == nil { + if isCN { + cp.Detail = "沙箱节点未配置" + } else { + cp.Detail = "No sandbox node configured" + } + return cp + } + + dockerNodeFound := false + for _, snap := range reg.List() { + if snap.Mode != "local" && !sandboxNodeOwnedBy(&snap, info) { + continue + } + if snap.Status != "online" || !snap.Capabilities.Docker { + continue + } + dockerNodeFound = true + + res, ok := tai.GetResources(snap.TaiID) + if !ok || res.Image == nil { + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + images, err := res.Image.List(ctx) + cancel() + if err != nil { + continue + } + + for _, img := range images { + for _, tag := range img.Tags { + if tag == imageRef { + cp.Status = "pass" + return cp + } + } + } + } + + if !dockerNodeFound { + if isCN { + cp.Detail = "Docker 未安装或节点离线" + } else { + cp.Detail = "Docker not installed or node offline" + } + } else { + if isCN { + cp.Detail = "镜像未下载" + } else { + cp.Detail = "Image not downloaded" + } + } + return cp +} + +func checkAssistantSearch(ast *assistant.Assistant, info *oauthTypes.AuthorizedInfo, isCN bool) Checkpoint { + cp := Checkpoint{ + Required: false, + Label: "Search", + Path: "/settings/search", + Status: "fail", + } + if isCN { + cp.Label = "搜索" + } + + if setting.Global == nil { + return cp + } + + // Check cloud search + cloudSaved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, cloudNS) + if cloudSaved != nil { + if v, ok := cloudSaved["status"].(string); ok && v == "connected" { + cp.Status = "pass" + return cp + } + } + + // Check any standalone search provider + for _, preset := range searchPresets { + if preset.IsCloud { + continue + } + saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, searchProviderNS(preset.Key)) + if saved != nil { + if v, ok := saved["status"].(string); ok && v == "connected" { + cp.Status = "pass" + return cp + } + } + } + + return cp +} + +func hasCapability(caps []string, target string) bool { + for _, c := range caps { + if c == target { + return true + } + } + return false +} diff --git a/openapi/setting/types.go b/openapi/setting/types.go index 2cfd044f..3226576c 100644 --- a/openapi/setting/types.go +++ b/openapi/setting/types.go @@ -219,3 +219,39 @@ type SandboxPageData struct { Registry SandboxRegistryConfig `json:"registry"` Images map[string][]SandboxImage `json:"images"` } + +// --------------------------------------------------------------------------- +// Setup Status +// --------------------------------------------------------------------------- + +type Checkpoint struct { + Status string `json:"status"` + Required bool `json:"required"` + Label string `json:"label"` + Path string `json:"path"` + Detail string `json:"detail,omitempty"` +} + +type SetupStatus struct { + Completed bool `json:"completed"` + Checkpoints map[string]Checkpoint `json:"checkpoints"` + OnboardingCompleted bool `json:"onboarding_completed"` + BannerDismissed bool `json:"banner_dismissed"` +} + +type AssistantSetupStatus struct { + AssistantID string `json:"assistant_id"` + AssistantName string `json:"assistant_name"` + Ready bool `json:"ready"` + Checkpoints map[string]Checkpoint `json:"checkpoints"` +} + +// --------------------------------------------------------------------------- +// User Preference +// --------------------------------------------------------------------------- + +type PreferenceData struct { + EmailNotification *bool `json:"email_notification,omitempty"` + BannerDismissed *bool `json:"banner_dismissed,omitempty"` + OnboardingCompleted *bool `json:"onboarding_completed,omitempty"` +}