Files
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

948 lines
37 KiB
Go

package types
import (
"database/sql/driver"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/utils"
"gorm.io/gorm"
)
// retrieverEngineMapping maps RETRIEVE_DRIVER values to retriever engine configurations
var retrieverEngineMapping = map[string][]RetrieverEngineParams{
"postgres": {
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: PostgresRetrieverEngineType},
{RetrieverType: VectorRetrieverType, RetrieverEngineType: PostgresRetrieverEngineType},
},
"elasticsearch_v7": {
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: ElasticsearchRetrieverEngineType},
},
"elasticsearch_v8": {
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: ElasticsearchRetrieverEngineType},
{RetrieverType: VectorRetrieverType, RetrieverEngineType: ElasticsearchRetrieverEngineType},
},
"qdrant": {
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: QdrantRetrieverEngineType},
{RetrieverType: VectorRetrieverType, RetrieverEngineType: QdrantRetrieverEngineType},
},
"milvus": {
{RetrieverType: VectorRetrieverType, RetrieverEngineType: MilvusRetrieverEngineType},
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: MilvusRetrieverEngineType},
},
"weaviate": {
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: WeaviateRetrieverEngineType},
{RetrieverType: VectorRetrieverType, RetrieverEngineType: WeaviateRetrieverEngineType},
},
"doris": {
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: DorisRetrieverEngineType},
{RetrieverType: VectorRetrieverType, RetrieverEngineType: DorisRetrieverEngineType},
},
"sqlite": {
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: SQLiteRetrieverEngineType},
{RetrieverType: VectorRetrieverType, RetrieverEngineType: SQLiteRetrieverEngineType},
},
"tencent_vectordb": {
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: TencentVectorDBRetrieverEngineType},
{RetrieverType: VectorRetrieverType, RetrieverEngineType: TencentVectorDBRetrieverEngineType},
},
"opensearch": {
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: OpenSearchRetrieverEngineType},
{RetrieverType: VectorRetrieverType, RetrieverEngineType: OpenSearchRetrieverEngineType},
},
}
// GetRetrieverEngineMapping returns the retriever engine mapping
// This allows other packages to access the driver capabilities
func GetRetrieverEngineMapping() map[string][]RetrieverEngineParams {
return retrieverEngineMapping
}
// GetDefaultRetrieverEngines returns the default retriever engines based on RETRIEVE_DRIVER env
func GetDefaultRetrieverEngines() []RetrieverEngineParams {
result := []RetrieverEngineParams{}
seen := make(map[string]bool)
for _, driver := range strings.Split(os.Getenv("RETRIEVE_DRIVER"), ",") {
driver = strings.TrimSpace(driver)
if params, ok := retrieverEngineMapping[driver]; ok {
for _, p := range params {
key := string(p.RetrieverType) + ":" + string(p.RetrieverEngineType)
if !seen[key] {
seen[key] = true
result = append(result, p)
}
}
}
}
return result
}
// Tenant represents the tenant
type Tenant struct {
// ID
ID uint64 `yaml:"id" json:"id" gorm:"primaryKey"`
// Name
Name string `yaml:"name" json:"name"`
// Description
Description string `yaml:"description" json:"description"`
// Status
Status string `yaml:"status" json:"status" gorm:"default:'active'"`
// Retriever engines
RetrieverEngines RetrieverEngines `yaml:"retriever_engines" json:"retriever_engines" gorm:"type:json"`
// Business
Business string `yaml:"business" json:"business"`
// Storage quota (Bytes), default is 10GB, including vector, original file, text, index, etc.
StorageQuota int64 `yaml:"storage_quota" json:"storage_quota" gorm:"default:10737418240"`
// Storage used (Bytes)
StorageUsed int64 `yaml:"storage_used" json:"storage_used" gorm:"default:0"`
// Global Context configuration for this workspace (default for all sessions)
ContextConfig *ContextConfig `yaml:"context_config" json:"context_config" gorm:"type:jsonb"`
// Global WebSearch configuration for this workspace
WebSearchConfig *WebSearchConfig `yaml:"web_search_config" json:"web_search_config" gorm:"type:jsonb"`
// Parser engine config overrides (MinerU endpoint, API key, etc.). Used when parsing documents; overrides env.
ParserEngineConfig *ParserEngineConfig `yaml:"parser_engine_config" json:"parser_engine_config" gorm:"type:jsonb"`
// Credentials config: third-party provider credentials (e.g. WeKnoraCloud AppID/AppSecret)
Credentials *CredentialsConfig `yaml:"credentials" json:"credentials" gorm:"type:jsonb"`
// Storage engine config: parameters for Local, MinIO, COS. Used for document/file storage and docreader.
StorageEngineConfig *StorageEngineConfig `yaml:"storage_engine_config" json:"storage_engine_config" gorm:"type:jsonb"`
// DefaultStorageBackendID is the workspace default concrete storage instance.
DefaultStorageBackendID *string `yaml:"default_storage_backend_id" json:"default_storage_backend_id,omitempty" gorm:"column:default_storage_backend_id;type:varchar(36)"`
// Chat history config: knowledge base configuration for indexing and searching chat messages via vector search
ChatHistoryConfig *ChatHistoryConfig `yaml:"chat_history_config" json:"chat_history_config" gorm:"type:jsonb"`
// Retrieval config: global search/retrieval parameters shared by knowledge search and message search
RetrievalConfig *RetrievalConfig `yaml:"retrieval_config" json:"retrieval_config" gorm:"type:jsonb"`
// Memory config: workspace switch for cross-session long-term memory
MemoryConfig *MemoryConfig `yaml:"memory_config" json:"memory_config" gorm:"type:jsonb"`
// API principal config: controls how X-API-Key requests map to terminal principals.
APIPrincipalConfig *APIPrincipalConfig `yaml:"api_principal_config" json:"-" gorm:"type:jsonb"`
// Creation time
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
// Last updated time
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
// Deletion time
DeletedAt gorm.DeletedAt `yaml:"deleted_at" json:"deleted_at" gorm:"index"`
}
// RetrieverEngines represents the retriever engines for a tenant
type RetrieverEngines struct {
Engines []RetrieverEngineParams `yaml:"engines" json:"engines" gorm:"type:json"`
}
// GetEffectiveEngines returns the tenant's engines if configured, otherwise returns system defaults
func (t *Tenant) GetEffectiveEngines() []RetrieverEngineParams {
if len(t.RetrieverEngines.Engines) > 0 {
return t.RetrieverEngines.Engines
}
return GetDefaultRetrieverEngines()
}
// BeforeCreate is a hook function that is called before creating a tenant
func (t *Tenant) BeforeCreate(tx *gorm.DB) error {
if t.RetrieverEngines.Engines == nil {
t.RetrieverEngines.Engines = []RetrieverEngineParams{}
}
return nil
}
// Value implements the driver.Valuer interface, used to convert RetrieverEngines to database value
func (c RetrieverEngines) Value() (driver.Value, error) {
return json.Marshal(c)
}
// Scan implements the sql.Scanner interface, used to convert database value to RetrieverEngines.
// It supports both the legacy bare-array format (e.g. [{...}, {...}]) and the current
// object-wrapped format (e.g. {"engines": [{...}, {...}]}).
func (c *RetrieverEngines) Scan(value interface{}) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
// Try the current object format first: {"engines": [...]}
if err := json.Unmarshal(b, c); err == nil {
return nil
}
// Fallback: legacy bare-array format: [{...}, {...}]
var engines []RetrieverEngineParams
if err := json.Unmarshal(b, &engines); err != nil {
return fmt.Errorf("retriever_engines: cannot unmarshal as object or array: %w", err)
}
c.Engines = engines
return nil
}
// CredentialsConfig holds third-party provider credentials at the tenant level.
// Stored as a single JSONB column; each provider is a nested object so new
// providers can be added without schema changes.
type CredentialsConfig struct {
WeKnoraCloud *WeKnoraCloudCredentials `json:"weknoracloud,omitempty"`
}
// WeKnoraCloudCredentials stores WeKnoraCloud AppID and AppSecret.
// AppSecret is AES-256 encrypted before persisting to database.
type WeKnoraCloudCredentials struct {
AppID string `json:"app_id"`
AppSecret string `json:"app_secret"`
}
type APIPrincipalMode string
const (
APIPrincipalModeTenant APIPrincipalMode = "tenant"
APIPrincipalModeDirect APIPrincipalMode = "direct_header"
APIPrincipalModeSignedToken APIPrincipalMode = "signed_token"
)
// APIPrincipalConfig controls how tenant API-key requests map to terminal
// principals. Direct header mode is low-assurance and should only be used for
// trusted server-to-server calls; signed-token mode verifies the user claim.
type APIPrincipalConfig struct {
Mode APIPrincipalMode `json:"mode"`
DirectHeaderName string `json:"direct_header_name,omitempty"`
SignedTokenHeaderName string `json:"signed_token_header_name,omitempty"`
// RequireDirectHeader, when true in direct_header mode, rejects API-key
// requests that omit the configured user-id header instead of falling
// back to the tenant-level principal.
RequireDirectHeader bool `json:"require_direct_header,omitempty"`
HMACSecret string `json:"hmac_secret,omitempty"`
}
func (c *APIPrincipalConfig) Value() (driver.Value, error) {
if c == nil {
return nil, nil
}
cp := *c
if cp.HMACSecret != "" {
if key := utils.GetAESKey(); key != nil {
if encrypted, err := utils.EncryptAESGCM(cp.HMACSecret, key); err == nil {
cp.HMACSecret = encrypted
}
}
}
return json.Marshal(&cp)
}
func (c *APIPrincipalConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
if err := json.Unmarshal(b, c); err != nil {
return err
}
if plain, ok := utils.DecryptStoredSecretLenient(c.HMACSecret); ok {
c.HMACSecret = plain
} else {
log.Printf("[crypto] tenant api_principal_config.hmac_secret: decrypt failed (SYSTEM_AES_KEY missing/rotated?), treating as unconfigured")
c.HMACSecret = ""
}
return nil
}
// GetWeKnoraCloud returns the WeKnoraCloud credentials, or nil if not configured.
func (c *CredentialsConfig) GetWeKnoraCloud() *WeKnoraCloudCredentials {
if c == nil || c.WeKnoraCloud == nil {
return nil
}
if c.WeKnoraCloud.AppID == "" || c.WeKnoraCloud.AppSecret == "" {
return nil
}
return c.WeKnoraCloud
}
// Value implements the driver.Valuer interface for CredentialsConfig
func (c *CredentialsConfig) Value() (driver.Value, error) {
if c == nil {
return nil, nil
}
cp := *c
if cp.WeKnoraCloud != nil && cp.WeKnoraCloud.AppSecret != "" {
if key := utils.GetAESKey(); key != nil {
if encrypted, err := utils.EncryptAESGCM(cp.WeKnoraCloud.AppSecret, key); err == nil {
cp.WeKnoraCloud = &WeKnoraCloudCredentials{AppID: cp.WeKnoraCloud.AppID, AppSecret: encrypted}
}
}
}
return json.Marshal(cp)
}
// Scan implements the sql.Scanner interface for CredentialsConfig
func (c *CredentialsConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
if err := json.Unmarshal(b, c); err != nil {
return err
}
if c.WeKnoraCloud != nil {
if plain, ok := utils.DecryptStoredSecretLenient(c.WeKnoraCloud.AppSecret); ok {
c.WeKnoraCloud.AppSecret = plain
} else {
log.Printf("[crypto] tenant credentials we_knora_cloud.app_secret: decrypt failed (SYSTEM_AES_KEY missing/rotated?), treating as unconfigured")
c.WeKnoraCloud.AppSecret = ""
}
}
return nil
}
// ParserEngineConfig holds tenant-level overrides for document parser engines (e.g. MinerU endpoint, API key).
// These values take precedence over environment variables when parsing documents.
type ParserEngineConfig struct {
// ChatParserEngineRules selects parser engines for session-scoped chat
// documents. Knowledge bases keep their own rules in ChunkingConfig.
ChatParserEngineRules []ParserEngineRule `json:"chat_parser_engine_rules,omitempty"`
MinerUEndpoint string `json:"mineru_endpoint"` // MinerU 自建服务端点
MinerUAPIKey string `json:"mineru_api_key"` // MinerU 云 API Key
// MinerU 自建解析参数
MinerUModel string `json:"mineru_model,omitempty"` // backend: pipeline, vlm-*, hybrid-*
MinerUVLMServerURL string `json:"mineru_vlm_server_url,omitempty"` // vLLM 服务器地址 (vlm-http-client / hybrid-http-client)
MinerUEnableFormula *bool `json:"mineru_enable_formula,omitempty"`
MinerUEnableTable *bool `json:"mineru_enable_table,omitempty"`
MinerUParseMethod string `json:"mineru_parse_method,omitempty"`
// MinerUEnableOCR is retained for compatibility with configurations saved
// before parse_method supported auto/ocr/txt.
MinerUEnableOCR *bool `json:"mineru_enable_ocr,omitempty"`
MinerULanguage string `json:"mineru_language,omitempty"`
// MinerU 云 API 解析参数
MinerUCloudModel string `json:"mineru_cloud_model,omitempty"` // model_version: pipeline, vlm, MinerU-HTML
MinerUCloudEnableFormula *bool `json:"mineru_cloud_enable_formula,omitempty"`
MinerUCloudEnableTable *bool `json:"mineru_cloud_enable_table,omitempty"`
MinerUCloudEnableOCR *bool `json:"mineru_cloud_enable_ocr,omitempty"`
MinerUCloudLanguage string `json:"mineru_cloud_language,omitempty"`
// OpenDataLoader PDF (docreader engine); hybrid requires opendataloader-pdf-hybrid service.
ODLHybrid string `json:"odl_hybrid,omitempty"` // off (default), docling-fast, hancom-ai
ODLHybridURL string `json:"odl_hybrid_url,omitempty"` // e.g. http://odl-hybrid:5002
ODLHybridMode string `json:"odl_hybrid_mode,omitempty"` // auto, full
ODLHybridFallback *bool `json:"odl_hybrid_fallback,omitempty"`
ODLMarkdownWithHTML *bool `json:"odl_markdown_with_html,omitempty"`
// PaddleOCR-VL self-hosted pipeline service (full /layout-parsing API).
PaddleOCRVLEndpoint string `json:"paddleocr_vl_endpoint,omitempty"` // e.g. http://paddleocr-vl:8080
PaddleOCRVLUseSealRecognition *bool `json:"paddleocr_vl_use_seal_recognition,omitempty"`
PaddleOCRVLUseChartRecognition *bool `json:"paddleocr_vl_use_chart_recognition,omitempty"`
// PaddleOCR-VL AI Studio cloud API.
PaddleOCRVLCloudToken string `json:"paddleocr_vl_cloud_token,omitempty"`
PaddleOCRVLCloudModel string `json:"paddleocr_vl_cloud_model,omitempty"` // e.g. PaddleOCR-VL-1.6
PaddleOCRVLCloudUseSealRecognition *bool `json:"paddleocr_vl_cloud_use_seal_recognition,omitempty"`
PaddleOCRVLCloudUseChartRecognition *bool `json:"paddleocr_vl_cloud_use_chart_recognition,omitempty"`
}
const (
MinerUParseMethodAuto = "auto"
MinerUParseMethodOCR = "ocr"
MinerUParseMethodText = "txt"
)
// ResolveMinerUParseMethod normalizes the explicit MinerU parse method and
// maps the legacy OCR toggle to the closest safe behavior. The old enabled
// value maps to auto instead of ocr so digital PDFs keep their native text
// layer while scanned PDFs are still detected and OCRed by MinerU.
func ResolveMinerUParseMethod(method string, legacyOCREnabled *bool) string {
switch strings.ToLower(strings.TrimSpace(method)) {
case MinerUParseMethodAuto:
return MinerUParseMethodAuto
case MinerUParseMethodOCR:
return MinerUParseMethodOCR
case MinerUParseMethodText:
return MinerUParseMethodText
}
if legacyOCREnabled != nil && !*legacyOCREnabled {
return MinerUParseMethodText
}
return MinerUParseMethodAuto
}
func (c *ParserEngineConfig) ResolveChatParserEngine(fileType string) string {
if c != nil {
normalized := normalizeParserFileType(fileType)
for _, rule := range c.ChatParserEngineRules {
for _, candidate := range rule.FileTypes {
if normalizeParserFileType(candidate) == normalized {
return strings.TrimSpace(rule.Engine)
}
}
}
}
return DefaultParserEngine(fileType)
}
// ToOverridesMap returns a map suitable for ParserEngineOverrides in parse requests.
// Keys are snake_case (mineru_endpoint, mineru_api_key, etc.).
func (c *ParserEngineConfig) ToOverridesMap() map[string]string {
if c == nil {
return nil
}
m := make(map[string]string)
if c.MinerUEndpoint != "" {
m["mineru_endpoint"] = c.MinerUEndpoint
}
if c.MinerUAPIKey != "" {
m["mineru_api_key"] = c.MinerUAPIKey
}
if c.MinerUModel != "" {
m["mineru_model"] = c.MinerUModel
}
if c.MinerUVLMServerURL != "" {
m["mineru_vlm_server_url"] = c.MinerUVLMServerURL
}
if c.MinerUEnableFormula != nil {
m["mineru_enable_formula"] = fmt.Sprintf("%v", *c.MinerUEnableFormula)
}
if c.MinerUEnableTable != nil {
m["mineru_enable_table"] = fmt.Sprintf("%v", *c.MinerUEnableTable)
}
if c.MinerUParseMethod != "" || c.MinerUEnableOCR != nil {
m["mineru_parse_method"] = ResolveMinerUParseMethod(c.MinerUParseMethod, c.MinerUEnableOCR)
}
if c.MinerUEnableOCR != nil {
m["mineru_enable_ocr"] = fmt.Sprintf("%v", *c.MinerUEnableOCR)
}
if c.MinerULanguage != "" {
m["mineru_language"] = c.MinerULanguage
}
if c.MinerUCloudModel != "" {
m["mineru_cloud_model"] = c.MinerUCloudModel
}
if c.MinerUCloudEnableFormula != nil {
m["mineru_cloud_enable_formula"] = fmt.Sprintf("%v", *c.MinerUCloudEnableFormula)
}
if c.MinerUCloudEnableTable != nil {
m["mineru_cloud_enable_table"] = fmt.Sprintf("%v", *c.MinerUCloudEnableTable)
}
if c.MinerUCloudEnableOCR != nil {
m["mineru_cloud_enable_ocr"] = fmt.Sprintf("%v", *c.MinerUCloudEnableOCR)
}
if c.MinerUCloudLanguage != "" {
m["mineru_cloud_language"] = c.MinerUCloudLanguage
}
if c.ODLHybrid != "" {
m["odl_hybrid"] = c.ODLHybrid
}
if c.ODLHybridURL != "" {
m["odl_hybrid_url"] = c.ODLHybridURL
}
if c.ODLHybridMode != "" {
m["odl_hybrid_mode"] = c.ODLHybridMode
}
if c.ODLHybridFallback != nil {
m["odl_hybrid_fallback"] = fmt.Sprintf("%v", *c.ODLHybridFallback)
}
if c.ODLMarkdownWithHTML != nil {
m["odl_markdown_with_html"] = fmt.Sprintf("%v", *c.ODLMarkdownWithHTML)
}
if c.PaddleOCRVLEndpoint != "" {
m["paddleocr_vl_endpoint"] = c.PaddleOCRVLEndpoint
}
if c.PaddleOCRVLUseSealRecognition != nil {
m["paddleocr_vl_use_seal_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLUseSealRecognition)
}
if c.PaddleOCRVLUseChartRecognition != nil {
m["paddleocr_vl_use_chart_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLUseChartRecognition)
}
if c.PaddleOCRVLCloudToken != "" {
m["paddleocr_vl_cloud_token"] = c.PaddleOCRVLCloudToken
}
if c.PaddleOCRVLCloudModel != "" {
m["paddleocr_vl_cloud_model"] = c.PaddleOCRVLCloudModel
}
if c.PaddleOCRVLCloudUseSealRecognition != nil {
m["paddleocr_vl_cloud_use_seal_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLCloudUseSealRecognition)
}
if c.PaddleOCRVLCloudUseChartRecognition != nil {
m["paddleocr_vl_cloud_use_chart_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLCloudUseChartRecognition)
}
if len(m) == 0 {
return nil
}
return m
}
// Value implements the driver.Valuer interface for ParserEngineConfig
func (c *ParserEngineConfig) Value() (driver.Value, error) {
if c == nil {
return nil, nil
}
return json.Marshal(c)
}
// Scan implements the sql.Scanner interface for ParserEngineConfig
func (c *ParserEngineConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(b, c)
}
// StorageEngineConfig holds tenant-level storage engine parameters for Local, MinIO, COS, TOS, S3, OSS, KS3, and OBS.
// Knowledge bases select which provider to use; parameters are read from here.
type StorageEngineConfig struct {
DefaultProvider string `json:"default_provider"` // "local", "minio", "cos", "tos", "s3", "oss", "ks3", "obs"
Local *LocalEngineConfig `json:"local,omitempty"`
MinIO *MinIOEngineConfig `json:"minio,omitempty"`
COS *COSEngineConfig `json:"cos,omitempty"`
TOS *TOSEngineConfig `json:"tos,omitempty"`
S3 *S3EngineConfig `json:"s3,omitempty"`
OSS *OSSEngineConfig `json:"oss,omitempty"`
KS3 *KS3EngineConfig `json:"ks3,omitempty"`
OBS *OBSEngineConfig `json:"obs,omitempty"`
}
// LocalEngineConfig is for local file system storage (single-machine deployment only).
type LocalEngineConfig struct {
PathPrefix string `json:"path_prefix"`
}
// MinIOEngineConfig is for MinIO/S3-compatible object storage.
// Mode "docker" uses env vars for endpoint/credentials; "remote" uses the fields below.
type MinIOEngineConfig struct {
Mode string `json:"mode"` // "docker" or "remote"
Endpoint string `json:"endpoint"`
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
BucketName string `json:"bucket_name"`
UseSSL bool `json:"use_ssl"`
PathPrefix string `json:"path_prefix"`
}
// COSEngineConfig is for Tencent Cloud COS.
type COSEngineConfig struct {
SecretID string `json:"secret_id"`
SecretKey string `json:"secret_key"`
Region string `json:"region"`
BucketName string `json:"bucket_name"`
AppID string `json:"app_id"`
PathPrefix string `json:"path_prefix"`
TempBucketName string `json:"temp_bucket_name"`
TempRegion string `json:"temp_region"`
}
// TOSEngineConfig is for Volcengine TOS (火山引擎对象存储).
type TOSEngineConfig struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
BucketName string `json:"bucket_name"`
PathPrefix string `json:"path_prefix"`
TempBucketName string `json:"temp_bucket_name"`
TempRegion string `json:"temp_region"`
}
// S3EngineConfig is for AWS S3 and S3-compatible object storage.
type S3EngineConfig struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
BucketName string `json:"bucket_name"`
PathPrefix string `json:"path_prefix"`
UseSSL bool `json:"use_ssl"`
ForcePathStyle bool `json:"force_path_style"`
}
// OSSEngineConfig is for Alibaba Cloud OSS (对象存储服务).
type OSSEngineConfig struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
BucketName string `json:"bucket_name"`
PathPrefix string `json:"path_prefix"`
UseTempBucket bool `json:"use_temp_bucket"`
TempBucketName string `json:"temp_bucket_name"`
TempRegion string `json:"temp_region"`
}
// KS3EngineConfig is for Kingsoft Cloud KS3 object storage.
type KS3EngineConfig struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
BucketName string `json:"bucket_name"`
PathPrefix string `json:"path_prefix"`
}
// OBSEngineConfig is for Huawei Cloud OBS (对象存储服务).
type OBSEngineConfig struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
BucketName string `json:"bucket_name"`
PathPrefix string `json:"path_prefix"`
UseSSL bool `json:"use_ssl"`
}
// Value implements the driver.Valuer interface for StorageEngineConfig
func (c *StorageEngineConfig) Value() (driver.Value, error) {
if c == nil {
return nil, nil
}
return json.Marshal(c)
}
// Scan implements the sql.Scanner interface for StorageEngineConfig
func (c *StorageEngineConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(b, c)
}
// TenantSandboxConfig is one named sandbox backend a workspace maintains.
//
// It is self-contained: provider fields are not inherited from process
// environment. Leaving a required provider field empty is rejected on save.
type TenantSandboxConfig struct {
// SandboxType is cube, e2b, or docker; disabled is the hidden policy row.
SandboxType string `json:"sandbox_type,omitempty"`
// ── 通用配置(跨后端生效)──────────────────────────────────
// DefaultTimeoutSec is the per-execution timeout in seconds. 0 uses the
// program's built-in default.
DefaultTimeoutSec int `json:"default_timeout_sec,omitempty"`
// AllowPrivateEndpoints permits this workspace config to reach RFC1918 or
// loopback cluster endpoints. Link-local/cloud-metadata addresses remain
// blocked. It is explicit in the UI instead of hidden in process env.
AllowPrivateEndpoints bool `json:"allow_private_endpoints,omitempty"`
// EnvVars are additional environment variables injected into every
// sandbox created for this tenant. 🔒 Values are encrypted at rest.
// These become visible to all scripts running in the tenant's
// sandboxes — do not place secrets here that scripts must not access.
EnvVars map[string]string `json:"env_vars,omitempty"`
// VolumeMount configures an optional shared volume mounted into every
// sandbox created for this tenant. Currently used for tenant-installed
// skills, but the configuration itself is skill-agnostic and can serve
// any volume-mount use case (shared datasets, pre-installed toolchains,
// etc.).
VolumeMount *VolumeMountConfig `json:"volume_mount,omitempty"`
// SkillImage points at the snapshot that carries this config's installed
// skills. Empty means "use the base template". Written only by the skill
// install/remove path: MergeSandboxConfigForUpdate ignores client values
// so a settings-form save cannot wipe or plant the pointer.
SkillImage *SkillImageConfig `json:"skill_image,omitempty"`
// SkillRollout decides whether sessions that already hold a sandbox of
// this config rebuild after a skill install or removal. Empty and
// SkillRolloutNextTurn rebuild on the next chat turn. SkillRolloutNewSession
// leaves those sandboxes on the previous image; only sessions that start
// afterwards boot the new snapshot.
SkillRollout string `json:"skill_rollout,omitempty"`
// Network is the outbound/inbound network policy applied to every sandbox
// created from this config — chat sessions, skill installs and deep
// connectivity probes alike. nil and the zero value mean the same thing:
// outbound egress allowed, inbound public access closed.
Network *SandboxNetworkPolicy `json:"network,omitempty"`
// ── 后端专属配置(同一时刻只有一个生效,由 SandboxType 决定)───
Cube *CubeSandboxConfig `json:"cube,omitempty"`
E2B *E2BSandboxConfig `json:"e2b,omitempty"`
Docker *DockerSandboxConfig `json:"docker,omitempty"`
}
// CubeSandboxConfig addresses one CubeSandbox deployment. APIURL, ProxyURL,
// SandboxDomain and TemplateID are all required; APIKey is optional because the
// common single-node setup runs unauthenticated.
type CubeSandboxConfig struct {
APIURL string `json:"api_url,omitempty"`
ProxyURL string `json:"proxy_url,omitempty"`
SandboxDomain string `json:"sandbox_domain,omitempty"`
APIKey string `json:"api_key,omitempty"` // 加密
TemplateID string `json:"template_id,omitempty"`
// HTTPTimeoutSec bounds each HTTP call to the sandbox control plane.
// 0 means use the built-in default (30s), never the deployment's value.
HTTPTimeoutSec int `json:"http_timeout_sec,omitempty"`
CubeSandboxTTLSeconds int `json:"cube_sandbox_ttl_seconds,omitempty"`
// DNSServers are Cube template nameserver IPs. Empty uses Cubelet's default.
DNSServers []string `json:"dns_servers,omitempty"`
}
// E2BSandboxConfig addresses one E2B-protocol control plane: E2B Cloud, a
// self-hosted E2B Infrastructure, or any E2B-compatible implementation
// (CubeSandbox, Agent-Sandbox, …). APIKey and TemplateID are required; APIURL
// and SandboxDomain are optional because go-e2b resolves both on its own when
// they are empty.
type E2BSandboxConfig struct {
APIURL string `json:"api_url,omitempty"`
SandboxDomain string `json:"sandbox_domain,omitempty"`
APIKey string `json:"api_key,omitempty"` // 加密
TemplateID string `json:"template_id,omitempty"`
// ProxyURL is the data-plane gateway that fronts envd. E2B Cloud resolves
// "<port>-<sandboxID>.<sandbox_domain>" through public DNS and TLS, so it
// needs no value here. Self-hosted E2B-compatible control planes usually
// serve every sandbox from one gateway address and expect the sandbox
// authority in the Host header; setting this makes WeKnora dial the
// gateway directly instead of requiring wildcard DNS and a certificate
// for the sandbox domain. An "http://" gateway also downgrades the
// data-plane scheme, which the E2B SDK otherwise pins to https.
ProxyURL string `json:"proxy_url,omitempty"`
// HTTPTimeoutSec bounds each HTTP call to the sandbox control plane.
// 0 means use the built-in default (30s), never the deployment's value.
HTTPTimeoutSec int `json:"http_timeout_sec,omitempty"`
E2BSandboxTTLSeconds int `json:"e2b_sandbox_ttl_seconds,omitempty"`
}
// DockerSandboxConfig addresses one Docker daemon. Image is required and plays
// the role a template ID plays for the MicroVM backends: every session
// container is created from it.
//
// The daemon endpoint is deliberately the only connection field, and TLS
// material is referenced by path rather than stored here. Client certificates
// are deployment infrastructure mounted onto the WeKnora host; keeping them
// out of the database keeps them out of backups, exports and API responses.
type DockerSandboxConfig struct {
Image string `json:"image,omitempty"`
// Host is the daemon endpoint in DOCKER_HOST form. Empty means the local
// unix socket.
Host string `json:"host,omitempty"`
// TLSCertPath is a directory on the WeKnora host containing ca.pem,
// cert.pem and key.pem. Required when Host is a TCP endpoint.
TLSCertPath string `json:"tls_cert_path,omitempty"`
// CPULimit is the number of CPU cores one sandbox may use. 0 uses the
// built-in default.
CPULimit float64 `json:"cpu_limit,omitempty"`
// MemoryLimitMB caps one sandbox's memory. 0 uses the built-in default.
MemoryLimitMB int `json:"memory_limit_mb,omitempty"`
// PidsLimit caps how many processes one sandbox may run. 0 uses the
// built-in default.
PidsLimit int `json:"pids_limit,omitempty"`
// NetworkMode is the Docker network sandboxes join: "bridge" (default) or
// "none" for no egress. Nothing else is accepted — host and container:
// modes share another namespace outright, and a named network is usually
// the deployment's own compose network, which would put the sandbox next
// to Postgres and Redis.
NetworkMode string `json:"network_mode,omitempty"`
// Runtime selects an alternative OCI runtime such as "runsc" (gVisor).
// Empty uses the daemon default.
Runtime string `json:"runtime,omitempty"`
// IdleTTLSeconds is how long a session container may go unused before it
// is reclaimed. The daemon has no idle timeout of its own, so this is what
// stops an abandoned session from pinning host memory indefinitely.
IdleTTLSeconds int `json:"idle_ttl_seconds,omitempty"`
// HTTPTimeoutSec bounds each Engine API call. 0 uses the built-in default.
HTTPTimeoutSec int `json:"http_timeout_sec,omitempty"`
}
// VolumeMountConfig configures a shared volume mount into every sandbox
// created for this tenant. Currently implemented for E2B volumes (used to
// share tenant-installed skills across sandbox sessions), but the schema is
// intentionally skill-agnostic so it can serve other volume-based use cases
// in the future.
type VolumeMountConfig struct {
// Enabled toggles the volume mount for this tenant.
Enabled bool `json:"enabled"`
// MountPath is the sandbox-internal path where the volume is mounted.
// Default: /weknora/tenant/skills (customizable per use case).
MountPath string `json:"mount_path,omitempty"`
// Provider identifies the volume backend. Currently "e2b" or "cube".
Provider string `json:"provider,omitempty"`
// VolumeID is the provider-specific volume identifier, populated after
// EnsureVolume / CreateVolume succeeds.
VolumeID string `json:"volume_id,omitempty"`
// VolumeName is the human-readable volume name, e.g.
// "weknora-tenant-<id>-skills".
VolumeName string `json:"volume_name,omitempty"`
// VolumeOwnerFingerprint = sha256(provider + APIKey + APIURL).
// Used to detect when the tenant switched to a different backend or
// API key, at which point the volume is no longer reachable and must
// be recreated.
VolumeOwnerFingerprint string `json:"volume_owner_fingerprint,omitempty"`
}
const (
// SkillRolloutNextTurn rebuilds a live session's sandbox on its next chat
// turn after the skill image changes. This is the default.
SkillRolloutNextTurn = "next_turn"
// SkillRolloutNewSession leaves live sandboxes on the previous image.
// Only a session that starts after the install or removal boots the new one.
SkillRolloutNewSession = "new_session"
)
// RebuildsExistingOnSkillChange reports whether an install or removal should
// mark already-bound sandboxes stale. Unknown values fail toward rebuilding
// so a corrupted row cannot pin every session on a retired image.
func (c *TenantSandboxConfig) RebuildsExistingOnSkillChange() bool {
if c == nil {
return true
}
return strings.TrimSpace(c.SkillRollout) != SkillRolloutNewSession
}
// SkillImageConfig is the pointer to the snapshot that carries the skills
// installed on this sandbox config. Snapshot IDs double as template IDs, so
// nothing else is needed to boot sessions from it.
//
// It holds no secrets, so it is not encrypted by TenantSandboxConfig.Value.
type SkillImageConfig struct {
// SnapshotID is the currently effective snapshot; empty = base template.
SnapshotID string `json:"snapshot_id,omitempty"`
// Generation increments on every successful install/remove, for naming
// and troubleshooting.
Generation int `json:"generation,omitempty"`
// BuiltAt records when this generation was produced.
BuiltAt time.Time `json:"built_at,omitempty"`
// BaseTemplateID is the template this chain was originally built from;
// the rebuild path starts over from it.
BaseTemplateID string `json:"base_template_id,omitempty"`
// OwnerFingerprint identifies the provider account that owns the snapshot.
// Snapshots are invisible across accounts, so a mismatch means "fall back
// to the base template" rather than "fail".
OwnerFingerprint string `json:"owner_fingerprint,omitempty"`
}
// Value implements the driver.Valuer interface. Every secret-bearing field
// (Cube.APIKey, E2B.APIKey, all EnvVars values, and injected header values) is
// encrypted before persisting. EnvVars are included because environment
// variables routinely carry credentials, and their values are handed to tenant
// scripts verbatim. The receiver is never mutated: nested structs and the map
// are copied first.
func (c *TenantSandboxConfig) Value() (driver.Value, error) {
if c == nil {
return nil, nil
}
cp := *c
key := utils.GetAESKey()
encrypt := func(plain string) string {
if plain == "" || key == nil {
return plain
}
encrypted, err := utils.EncryptAESGCM(plain, key)
if err != nil {
return plain
}
return encrypted
}
if c.Cube != nil {
cube := *c.Cube
cube.APIKey = encrypt(cube.APIKey)
cp.Cube = &cube
}
if c.E2B != nil {
e2b := *c.E2B
e2b.APIKey = encrypt(e2b.APIKey)
cp.E2B = &e2b
}
// Keys stay readable so operators can still see which variables are set.
if len(c.EnvVars) > 0 {
envVars := make(map[string]string, len(c.EnvVars))
for name, value := range c.EnvVars {
envVars[name] = encrypt(value)
}
cp.EnvVars = envVars
}
// Injected headers are the sandbox-side way to call an API without the
// credential ever entering the sandbox, so their values are secrets.
if c.Network != nil {
cp.Network = c.Network.CloneWithSecrets(encrypt)
}
return json.Marshal(&cp)
}
// Scan implements the sql.Scanner interface. Secrets that cannot be decrypted
// (missing or rotated SYSTEM_AES_KEY) are blanked and logged rather than
// failing the whole load, matching ModelParameters.Scan — a tenant must stay
// listable even if its sandbox credentials became unreadable.
func (c *TenantSandboxConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
if err := json.Unmarshal(b, c); err != nil {
return err
}
decrypt := func(stored, label string) string {
if stored == "" {
return ""
}
if plain, ok := utils.DecryptStoredSecretLenient(stored); ok {
return plain
}
log.Printf("[crypto] tenant_sandbox_config.%s: decrypt failed "+
"(SYSTEM_AES_KEY missing/rotated?), treating as unconfigured", label)
return ""
}
if c.Cube != nil {
c.Cube.APIKey = decrypt(c.Cube.APIKey, "cube.api_key")
}
if c.E2B != nil {
c.E2B.APIKey = decrypt(c.E2B.APIKey, "e2b.api_key")
}
for name, stored := range c.EnvVars {
c.EnvVars[name] = decrypt(stored, "env_vars."+name)
}
if c.Network != nil {
// CloneWithSecrets has no rule/header context, so rotated-key failures
// share one label rather than identifying the individual credential.
c.Network = c.Network.CloneWithSecrets(func(stored string) string {
return decrypt(stored, "network.injected_header")
})
}
return nil
}