yao/openapi/setting/setup_status.go
Max d6626b2a7e refactor(setup): update preference retrieval to user-scope only
- Modified the handleSetupStatus function to read user-specific preferences instead of merged settings, ensuring that personal preferences do not inherit from system or team scopes.
- This change enhances the accuracy of user preference handling in the setup status process.
2026-05-05 08:56:13 +08:00

610 lines
14 KiB
Go

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 {
// Read user-scope only: these are personal preferences that must not
// inherit from system/team scopes.
prefs, _ := setting.Global.Get(preferenceScope(info), 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
}