diff --git a/agent/assistant/assistant.go b/agent/assistant/assistant.go index 7a8d723c..9b74ae58 100644 --- a/agent/assistant/assistant.go +++ b/agent/assistant/assistant.go @@ -479,11 +479,16 @@ func (ast *Assistant) GetInfo(locale ...string) *store.AssistantInfo { } info := &store.AssistantInfo{ - AssistantID: ast.ID, - Avatar: ast.Avatar, + AssistantID: ast.ID, + Avatar: ast.Avatar, + Connector: ast.Connector, + ConnectorOptions: ast.ConnectorOptions, + Modes: ast.Modes, + DefaultMode: ast.DefaultMode, + Sandbox: ast.IsSandbox, + ComputerFilter: ast.ComputerFilter, } - // Apply i18n translation if locale is provided if loc != "" { info.Name = ast.GetName(loc) info.Description = ast.GetDescription(loc) diff --git a/agent/assistant/load.go b/agent/assistant/load.go index 75092b47..7542e2d2 100644 --- a/agent/assistant/load.go +++ b/agent/assistant/load.go @@ -402,6 +402,12 @@ func LoadPath(path string) (*Assistant, error) { ast.SandboxV2 = sbCfg } + // Extract Sandbox flag and ComputerFilter from V2 sandbox config. + if ast.SandboxV2 != nil { + ast.IsSandbox = true + ast.ComputerFilter = ast.SandboxV2.Filter + } + // Compute config hash for V2 sandbox. if ast.SandboxV2 != nil { var mcpServers []store.MCPServerConfig @@ -772,6 +778,8 @@ func loadMap(data map[string]interface{}) (*Assistant, error) { return nil, err } assistant.SandboxV2 = sb + assistant.IsSandbox = true + assistant.ComputerFilter = sb.Filter } else { sb, err := store.ToSandbox(sandbox) if err != nil { diff --git a/agent/sandbox/v2/types/config.go b/agent/sandbox/v2/types/config.go index 2f5a57af..9eab224e 100644 --- a/agent/sandbox/v2/types/config.go +++ b/agent/sandbox/v2/types/config.go @@ -23,6 +23,7 @@ type SandboxConfig struct { Prepare []PrepareStep `json:"prepare,omitempty" yaml:"prepare,omitempty"` Environment map[string]string `json:"environment,omitempty" yaml:"environment,omitempty"` Secrets map[string]string `json:"secrets,omitempty" yaml:"secrets,omitempty"` + Filter *ComputerFilter `json:"filter,omitempty" yaml:"filter,omitempty"` // Populated by the framework at runtime (never serialized). Owner string `json:"-" yaml:"-"` @@ -33,6 +34,19 @@ type SandboxConfig struct { WorkspaceID string `json:"-" yaml:"-"` } +// ComputerFilter defines the query parameters for GET /computer/options. +// Declared in DSL sandbox.filter; frontend passes it through to the API. +type ComputerFilter struct { + Kind string `json:"kind,omitempty" yaml:"kind,omitempty"` + Image string `json:"image,omitempty" yaml:"image,omitempty"` + VNC *bool `json:"vnc,omitempty" yaml:"vnc,omitempty"` + OS string `json:"os,omitempty" yaml:"os,omitempty"` + Arch string `json:"arch,omitempty" yaml:"arch,omitempty"` + MinCPUs float64 `json:"min_cpus,omitempty" yaml:"min_cpus,omitempty"` + MinMem string `json:"min_mem,omitempty" yaml:"min_mem,omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` +} + // ComputerConfig describes the execution environment (container or host). type ComputerConfig struct { Image string `json:"image,omitempty" yaml:"image,omitempty"` diff --git a/agent/store/types/types.go b/agent/store/types/types.go index 4063b285..3ca2edfa 100644 --- a/agent/store/types/types.go +++ b/agent/store/types/types.go @@ -207,10 +207,16 @@ type AssistantList struct { // AssistantInfo contains basic assistant information for display // Used in chat history to show assistant details with i18n support type AssistantInfo struct { - AssistantID string `json:"assistant_id"` - Name string `json:"name"` - Avatar string `json:"avatar,omitempty"` - Description string `json:"description,omitempty"` + AssistantID string `json:"assistant_id"` + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + Description string `json:"description,omitempty"` + Connector string `json:"connector,omitempty"` + ConnectorOptions *ConnectorOptions `json:"connector_options,omitempty"` + Modes []string `json:"modes,omitempty"` + DefaultMode string `json:"default_mode,omitempty"` + Sandbox bool `json:"sandbox,omitempty"` + ComputerFilter *sandboxTypes.ComputerFilter `json:"computer_filter,omitempty"` } // Tag represents a tag @@ -422,44 +428,46 @@ type ConnectorOptions struct { // AssistantModel the assistant database model type AssistantModel struct { - ID string `json:"assistant_id"` // Assistant ID - Type string `json:"type,omitempty"` // Assistant Type, default is assistant - Name string `json:"name,omitempty"` // Assistant Name - Avatar string `json:"avatar,omitempty"` // Assistant Avatar - Connector string `json:"connector"` // AI Connector (default connector) - ConnectorOptions *ConnectorOptions `json:"connector_options,omitempty"` // Connector selection options for user to choose from - Path string `json:"path,omitempty"` // Assistant Path - BuiltIn bool `json:"built_in,omitempty"` // Whether this is a built-in assistant - Sort int `json:"sort,omitempty"` // Assistant Sort - Description string `json:"description,omitempty"` // Assistant Description - Capabilities string `json:"capabilities,omitempty"` // Assistant capabilities description (useful for Robot orchestration) - Tags []string `json:"tags,omitempty"` // Assistant Tags - Modes []string `json:"modes,omitempty"` // Supported modes (e.g., ["task", "chat"]), null means all modes are supported - DefaultMode string `json:"default_mode,omitempty"` // Default mode, can be empty - Readonly bool `json:"readonly,omitempty"` // Whether this assistant is readonly - Public bool `json:"public,omitempty"` // Whether this assistant is shared across all teams in the platform - Share string `json:"share,omitempty"` // Assistant sharing scope (private/team) - Mentionable bool `json:"mentionable,omitempty"` // Whether this assistant is mentionable - Automated bool `json:"automated,omitempty"` // Whether this assistant is automated - Options map[string]interface{} `json:"options,omitempty"` // AI Options - Prompts []Prompt `json:"prompts,omitempty"` // AI Prompts (default prompts) - PromptPresets map[string][]Prompt `json:"prompt_presets,omitempty"` // Prompt presets organized by mode (e.g., "chat", "task", etc.) - DisableGlobalPrompts bool `json:"disable_global_prompts,omitempty"` // Whether to disable global prompts, default is false - KB *KnowledgeBase `json:"kb,omitempty"` // Knowledge base configuration - DB *Database `json:"db,omitempty"` // Database configuration - MCP *MCPServers `json:"mcp,omitempty"` // MCP servers configuration - Workflow *Workflow `json:"workflow,omitempty"` // Workflow configuration - Sandbox *Sandbox `json:"sandbox,omitempty"` // Sandbox configuration for coding agents (V1) - SandboxV2 *sandboxTypes.SandboxConfig `json:"-"` // V2 sandbox configuration (runtime only, not persisted in DB) - ConfigHash string `json:"-"` // V2 sandbox config fingerprint for hot-reload - Placeholder *Placeholder `json:"placeholder,omitempty"` // Assistant Placeholder - Source string `json:"source,omitempty"` // Hook script source code - Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales - Uses *context.Uses `json:"uses,omitempty"` // Assistant-specific wrapper configurations for vision, audio, etc. If not set, use global settings - Search *searchTypes.Config `json:"search,omitempty"` // Search configuration (web, kb, db, citation, weights, etc.) - Dependencies map[string]string `json:"dependencies,omitempty"` // Dependencies on other MCP Clients (name -> version constraint) - CreatedAt int64 `json:"created_at"` // Creation timestamp - UpdatedAt int64 `json:"updated_at"` // Last update timestamp + ID string `json:"assistant_id"` // Assistant ID + Type string `json:"type,omitempty"` // Assistant Type, default is assistant + Name string `json:"name,omitempty"` // Assistant Name + Avatar string `json:"avatar,omitempty"` // Assistant Avatar + Connector string `json:"connector"` // AI Connector (default connector) + ConnectorOptions *ConnectorOptions `json:"connector_options,omitempty"` // Connector selection options for user to choose from + Path string `json:"path,omitempty"` // Assistant Path + BuiltIn bool `json:"built_in,omitempty"` // Whether this is a built-in assistant + Sort int `json:"sort,omitempty"` // Assistant Sort + Description string `json:"description,omitempty"` // Assistant Description + Capabilities string `json:"capabilities,omitempty"` // Assistant capabilities description (useful for Robot orchestration) + Tags []string `json:"tags,omitempty"` // Assistant Tags + Modes []string `json:"modes,omitempty"` // Supported modes (e.g., ["task", "chat"]), null means all modes are supported + DefaultMode string `json:"default_mode,omitempty"` // Default mode, can be empty + Readonly bool `json:"readonly,omitempty"` // Whether this assistant is readonly + Public bool `json:"public,omitempty"` // Whether this assistant is shared across all teams in the platform + Share string `json:"share,omitempty"` // Assistant sharing scope (private/team) + Mentionable bool `json:"mentionable,omitempty"` // Whether this assistant is mentionable + Automated bool `json:"automated,omitempty"` // Whether this assistant is automated + Options map[string]interface{} `json:"options,omitempty"` // AI Options + Prompts []Prompt `json:"prompts,omitempty"` // AI Prompts (default prompts) + PromptPresets map[string][]Prompt `json:"prompt_presets,omitempty"` // Prompt presets organized by mode (e.g., "chat", "task", etc.) + DisableGlobalPrompts bool `json:"disable_global_prompts,omitempty"` // Whether to disable global prompts, default is false + KB *KnowledgeBase `json:"kb,omitempty"` // Knowledge base configuration + DB *Database `json:"db,omitempty"` // Database configuration + MCP *MCPServers `json:"mcp,omitempty"` // MCP servers configuration + Workflow *Workflow `json:"workflow,omitempty"` // Workflow configuration + Sandbox *Sandbox `json:"sandbox,omitempty"` // Sandbox configuration for coding agents (V1) + SandboxV2 *sandboxTypes.SandboxConfig `json:"-"` // V2 sandbox configuration (runtime only, not persisted in DB) + IsSandbox bool `json:"-"` // Whether this is a Sandbox assistant (derived from SandboxV2 presence) + ComputerFilter *sandboxTypes.ComputerFilter `json:"-"` // Computer filter from DSL sandbox.filter (runtime only) + ConfigHash string `json:"-"` // V2 sandbox config fingerprint for hot-reload + Placeholder *Placeholder `json:"placeholder,omitempty"` // Assistant Placeholder + Source string `json:"source,omitempty"` // Hook script source code + Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales + Uses *context.Uses `json:"uses,omitempty"` // Assistant-specific wrapper configurations for vision, audio, etc. If not set, use global settings + Search *searchTypes.Config `json:"search,omitempty"` // Search configuration (web, kb, db, citation, weights, etc.) + Dependencies map[string]string `json:"dependencies,omitempty"` // Dependencies on other MCP Clients (name -> version constraint) + CreatedAt int64 `json:"created_at"` // Creation timestamp + UpdatedAt int64 `json:"updated_at"` // Last update timestamp // Permission management fields (not exposed in JSON API responses) YaoCreatedBy string `json:"-"` // User who created the assistant (not exposed in JSON) diff --git a/openapi/agent/assistant.go b/openapi/agent/assistant.go index 08423a0f..33967535 100644 --- a/openapi/agent/assistant.go +++ b/openapi/agent/assistant.go @@ -8,7 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/yaoapp/kun/log" "github.com/yaoapp/yao/agent" - "github.com/yaoapp/yao/agent/assistant" + assistantPkg "github.com/yaoapp/yao/agent/assistant" agenttypes "github.com/yaoapp/yao/agent/store/types" "github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/types" @@ -415,13 +415,13 @@ func CreateAssistant(c *gin.Context) { assistantData["assistant_id"] = id // Clear cache and reload assistant to make it effective - cache := assistant.GetCache() + cache := assistantPkg.GetCache() if cache != nil { cache.Remove(id) } // Reload the assistant to ensure it's available in cache with updated data - _, err = assistant.Get(id) + _, err = assistantPkg.Get(id) if err != nil { // Just log the error, don't fail the request log.Error("Error reloading assistant %s: %v", id, err) @@ -520,13 +520,13 @@ func UpdateAssistant(c *gin.Context) { } // Clear cache and reload assistant to make it effective - cache := assistant.GetCache() + cache := assistantPkg.GetCache() if cache != nil { cache.Remove(assistantID) } // Reload the assistant to ensure it's available in cache with updated data - _, err = assistant.Get(assistantID) + _, err = assistantPkg.Get(assistantID) if err != nil { // Just log the error, don't fail the request log.Error("Error reloading assistant %s: %v", assistantID, err) @@ -539,24 +539,10 @@ func UpdateAssistant(c *gin.Context) { } // GetAssistantInfo retrieves essential assistant information for InputArea component -// Returns only the fields needed for UI display: id, name, avatar, description, connector, connector_options, modes, default_mode func GetAssistantInfo(c *gin.Context) { - // Get authorized information authInfo := authorized.GetInfo(c) - // Get Agent instance from global variable - agentInstance := agent.GetAgent() - if agentInstance == nil || agentInstance.Store == nil { - errorResp := &response.ErrorResponse{ - Code: response.ErrServerError.Code, - ErrorDescription: "Agent store not initialized", - } - response.RespondWithError(c, response.StatusInternalServerError, errorResp) - return - } - - // Get assistant ID from URL parameter assistantID := c.Param("id") if assistantID == "" { errorResp := &response.ErrorResponse{ @@ -567,37 +553,11 @@ func GetAssistantInfo(c *gin.Context) { return } - // Parse locale (optional - defaults to "en-us") locale := "en-us" if loc := c.Query("locale"); loc != "" { locale = strings.ToLower(strings.TrimSpace(loc)) } - // Define fields needed for InputArea - infoFields := []string{ - "assistant_id", - "name", - "avatar", - "description", - "connector", - "connector_options", - "modes", - "default_mode", - } - - // Get assistant with specific fields and locale - assistant, err := agentInstance.Store.GetAssistant(assistantID, infoFields, locale) - if err != nil { - log.Error("Failed to get assistant info %s: %v", assistantID, err) - errorResp := &response.ErrorResponse{ - Code: response.ErrInvalidRequest.Code, - ErrorDescription: "Assistant not found: " + err.Error(), - } - response.RespondWithError(c, response.StatusNotFound, errorResp) - return - } - - // Check read permission (same as GetAssistant) hasPermission, err := checkAssistantPermission(authInfo, assistantID, true) if err != nil { log.Error("Failed to check permission for assistant %s: %v", assistantID, err) @@ -618,28 +578,18 @@ func GetAssistantInfo(c *gin.Context) { return } - // Build response with only the required fields - infoResponse := map[string]interface{}{ - "assistant_id": assistant.ID, - "name": assistant.Name, - "avatar": assistant.Avatar, - "description": assistant.Description, - "connector": assistant.Connector, + ast, err := assistantPkg.Get(assistantID) + if err != nil || ast == nil { + log.Error("Failed to get assistant info %s: %v", assistantID, err) + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Assistant not found", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return } - // Add optional fields if they exist - if assistant.ConnectorOptions != nil { - infoResponse["connector_options"] = assistant.ConnectorOptions - } - if len(assistant.Modes) > 0 { - infoResponse["modes"] = assistant.Modes - } - if assistant.DefaultMode != "" { - infoResponse["default_mode"] = assistant.DefaultMode - } - - // Return the result with standard response format - response.RespondWithSuccess(c, response.StatusOK, infoResponse) + response.RespondWithSuccess(c, response.StatusOK, ast.GetInfo(locale)) } // checkAssistantPermission checks if the user has permission to access the assistant diff --git a/openapi/computer/computer.go b/openapi/computer/computer.go new file mode 100644 index 00000000..b694504c --- /dev/null +++ b/openapi/computer/computer.go @@ -0,0 +1,344 @@ +package computer + +import ( + "context" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + sandboxv2 "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/registry" + + "github.com/yaoapp/yao/openapi/oauth/authorized" + oauthTypes "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/response" +) + +// Attach registers computer option routes on the given group. +// - GET /options — list available computers (filtered by ComputerFilter query params) +func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) { + group.Use(oauth.Guard) + group.GET("/options", handleOptions) +} + +type computerSystemInfo struct { + OS string `json:"os"` + Arch string `json:"arch"` + Hostname string `json:"hostname"` + NumCPU int `json:"num_cpu"` + TotalMem int64 `json:"total_mem,omitempty"` +} + +type computerOption struct { + Kind string `json:"kind"` + ID string `json:"id"` + DisplayName string `json:"display_name"` + NodeID string `json:"node_id"` + Status string `json:"status"` + Mode string `json:"mode,omitempty"` + Addr string `json:"addr,omitempty"` + Image string `json:"image,omitempty"` + Policy string `json:"policy,omitempty"` + VNC bool `json:"vnc"` + Labels map[string]string `json:"labels,omitempty"` + System computerSystemInfo `json:"system"` +} + +func handleOptions(c *gin.Context) { + authInfo := authorized.GetInfo(c) + + kindFilter := c.Query("kind") + imageFilter := c.Query("image") + osFilter := c.Query("os") + archFilter := c.Query("arch") + + var vncFilter *bool + if v := c.Query("vnc"); v != "" { + b, _ := strconv.ParseBool(v) + vncFilter = &b + } + + var minCPUs float64 + if v := c.Query("min_cpus"); v != "" { + minCPUs, _ = strconv.ParseFloat(v, 64) + } + + var minMem int64 + if v := c.Query("min_mem"); v != "" { + minMem = parseMemString(v) + } + + var result []computerOption + + reg := registry.Global() + if reg == nil { + response.RespondWithSuccess(c, http.StatusOK, []computerOption{}) + return + } + + snaps := reg.List() + + // Host entries: nodes with host_exec capability + if kindFilter == "" || kindFilter == "host" { + for i := range snaps { + s := &snaps[i] + if !nodeOwnedBy(s, authInfo) { + continue + } + if !s.Capabilities["host_exec"] { + continue + } + if !matchNodeFilter(s, osFilter, archFilter, minCPUs, minMem) { + continue + } + result = append(result, nodeToHostOption(*s)) + } + } + + // Node entries: nodes with container runtime capability + if kindFilter == "" || kindFilter == "node" { + for i := range snaps { + s := &snaps[i] + if !nodeOwnedBy(s, authInfo) { + continue + } + hasRuntime := s.Capabilities["docker"] || s.Capabilities["k8s"] + if !hasRuntime { + continue + } + if !matchNodeFilter(s, osFilter, archFilter, minCPUs, minMem) { + continue + } + result = append(result, nodeToNodeOption(*s)) + } + } + + // Box entries: persistent/longrunning boxes only + if kindFilter == "" || kindFilter == "box" { + if mgr := getManager(); mgr != nil { + owner := resolveOwner(authInfo) + boxes, err := mgr.List(context.Background(), sandboxv2.ListOptions{}) + if err == nil { + for _, b := range boxes { + snap := b.Snapshot() + if snap.Owner != owner { + continue + } + if snap.Policy != sandboxv2.Persistent && snap.Policy != sandboxv2.LongRunning { + continue + } + if imageFilter != "" && snap.Image != imageFilter { + continue + } + if vncFilter != nil && snap.VNC != *vncFilter { + continue + } + result = append(result, boxToOption(b)) + } + } + } + } + + if result == nil { + result = []computerOption{} + } + response.RespondWithSuccess(c, http.StatusOK, result) +} + +func matchNodeFilter(s *registry.NodeSnapshot, osFilter, archFilter string, minCPUs float64, minMem int64) bool { + if osFilter != "" && !strings.EqualFold(s.System.OS, osFilter) { + return false + } + if archFilter != "" && !strings.EqualFold(s.System.Arch, archFilter) { + return false + } + if minCPUs > 0 && float64(s.System.NumCPU) < minCPUs { + return false + } + if minMem > 0 && s.System.TotalMem < minMem { + return false + } + return true +} + +func nodeToHostOption(s registry.NodeSnapshot) computerOption { + displayName := s.DisplayName + if displayName == "" { + displayName = s.System.Hostname + } + if displayName == "" { + displayName = s.TaiID + } + + status := "stopped" + if s.Status == "online" { + status = "running" + } + + addr := s.Addr + if addr == "" { + scheme := s.Mode + if scheme == "" { + scheme = "tai" + } + addr = scheme + "://" + s.TaiID + } + + return computerOption{ + Kind: "host", + ID: s.TaiID, + DisplayName: displayName, + NodeID: s.TaiID, + Status: status, + Mode: s.Mode, + Addr: addr, + System: computerSystemInfo{ + OS: s.System.OS, + Arch: s.System.Arch, + Hostname: s.System.Hostname, + NumCPU: s.System.NumCPU, + TotalMem: s.System.TotalMem, + }, + } +} + +func nodeToNodeOption(s registry.NodeSnapshot) computerOption { + displayName := s.DisplayName + if displayName == "" { + displayName = s.System.Hostname + } + if displayName == "" { + displayName = s.TaiID + } + + status := "stopped" + if s.Status == "online" { + status = "running" + } + + addr := s.Addr + if addr == "" { + scheme := s.Mode + if scheme == "" { + scheme = "tai" + } + addr = scheme + "://" + s.TaiID + } + + return computerOption{ + Kind: "node", + ID: s.TaiID, + DisplayName: displayName, + NodeID: s.TaiID, + Status: status, + Mode: s.Mode, + Addr: addr, + System: computerSystemInfo{ + OS: s.System.OS, + Arch: s.System.Arch, + Hostname: s.System.Hostname, + NumCPU: s.System.NumCPU, + TotalMem: s.System.TotalMem, + }, + } +} + +func boxToOption(b *sandboxv2.Box) computerOption { + snap := b.Snapshot() + info := b.ComputerInfo() + + displayName := info.System.Hostname + if displayName == "" { + displayName = snap.ID + } + + var mode, addr string + if ns, ok := tai.GetNodeSnapshot(snap.NodeID); ok { + mode = ns.Mode + addr = ns.Addr + } + if addr == "" && snap.NodeID != "" { + scheme := mode + if scheme == "" { + scheme = "local" + } + addr = scheme + "://" + snap.NodeID + } + + return computerOption{ + Kind: "box", + ID: snap.ID, + DisplayName: displayName, + NodeID: snap.NodeID, + Status: snap.Status, + Mode: mode, + Addr: addr, + Image: snap.Image, + Policy: string(snap.Policy), + VNC: snap.VNC, + Labels: snap.Labels, + System: computerSystemInfo{ + OS: info.System.OS, + Arch: info.System.Arch, + Hostname: info.System.Hostname, + NumCPU: info.System.NumCPU, + TotalMem: info.System.TotalMem, + }, + } +} + +func nodeOwnedBy(snap *registry.NodeSnapshot, authInfo *oauthTypes.AuthorizedInfo) bool { + if authInfo == nil { + return true + } + if authInfo.TeamID != "" { + return snap.Auth.TeamID == authInfo.TeamID + } + if authInfo.UserID != "" { + return snap.Auth.TeamID == "" && snap.Auth.UserID == authInfo.UserID + } + return true +} + +func resolveOwner(authInfo *oauthTypes.AuthorizedInfo) string { + if authInfo != nil && authInfo.TeamID != "" { + return authInfo.TeamID + } + if authInfo != nil { + return authInfo.UserID + } + return "" +} + +func getManager() *sandboxv2.Manager { + defer func() { recover() }() + return sandboxv2.M() +} + +func parseMemString(s string) int64 { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return 0 + } + + multiplier := int64(1) + switch { + case strings.HasSuffix(s, "g"): + multiplier = 1024 * 1024 * 1024 + s = strings.TrimSuffix(s, "g") + case strings.HasSuffix(s, "m"): + multiplier = 1024 * 1024 + s = strings.TrimSuffix(s, "m") + case strings.HasSuffix(s, "k"): + multiplier = 1024 + s = strings.TrimSuffix(s, "k") + } + + val, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return int64(val * float64(multiplier)) +} diff --git a/openapi/openapi.go b/openapi/openapi.go index dd5c6c9d..92f66d68 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -10,6 +10,7 @@ import ( "github.com/yaoapp/yao/openapi/app" "github.com/yaoapp/yao/openapi/captcha" "github.com/yaoapp/yao/openapi/chat" + openapiComputer "github.com/yaoapp/yao/openapi/computer" "github.com/yaoapp/yao/openapi/dsl" "github.com/yaoapp/yao/openapi/file" "github.com/yaoapp/yao/openapi/hello" @@ -181,6 +182,9 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) { sandbox.Attach(sandboxGroup, openapi.OAuth) sandbox.AttachManage(sandboxGroup) + // Computer option handlers (for InputArea selector) + openapiComputer.Attach(group.Group("/computer"), openapi.OAuth) + // Workspace handlers openapiWorkspace.Attach(group.Group("/workspace"), openapi.OAuth) diff --git a/openapi/workspace/workspace.go b/openapi/workspace/workspace.go index 5a550199..d5b5d5dd 100644 --- a/openapi/workspace/workspace.go +++ b/openapi/workspace/workspace.go @@ -32,6 +32,7 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) { group.Use(oauth.Guard) group.GET("", handleList) + group.GET("/options", handleOptions) group.POST("", handleCreate) group.GET("/:id", handleGet) group.PUT("/:id", handleUpdate) @@ -170,6 +171,35 @@ func handleList(c *gin.Context) { response.RespondWithSuccess(c, http.StatusOK, result) } +// handleOptions returns workspace options for the InputArea selector. +// Reuses the same logic as handleList (Manager.List with owner+node filter). +// Separated as a dedicated endpoint for clear API responsibility boundary. +func handleOptions(c *gin.Context) { + m := mgr() + if m == nil { + response.RespondWithSuccess(c, http.StatusOK, []workspaceResponse{}) + return + } + + authInfo := authorized.GetInfo(c) + owner := resolveOwner(authInfo) + + list, err := m.List(context.Background(), ws.ListOptions{ + Owner: owner, + Node: c.Query("node"), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + result := make([]workspaceResponse, 0, len(list)) + for _, w := range list { + result = append(result, toResponse(w)) + } + response.RespondWithSuccess(c, http.StatusOK, result) +} + func handleCreate(c *gin.Context) { m := mgr() if m == nil {