feat(chat): improve error handling for chat operations

- Added centralized error handling for "not found" scenarios in GetChat, UpdateChat, DeleteChat, and GetMessages functions, enhancing user feedback for missing resources.
- Introduced isNotFoundError and respondNotFound utility functions to streamline error responses across chat-related operations.
- Updated the setting package to include new endpoints for setup status and user preferences, improving API functionality and user experience.
This commit is contained in:
Max 2026-05-04 21:53:26 +08:00
parent cf0ebd5601
commit 01daa783e0
5 changed files with 785 additions and 8 deletions

View file

@ -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) {

View file

@ -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
}

View file

@ -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.

View file

@ -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
}

View file

@ -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"`
}