From 78e82eee2b33c839faff94ec843ce6981a0caaf7 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 11 Sep 2025 18:13:24 -0700 Subject: [PATCH 1/8] implement atommeta (for schema) and documentation --- tsunami/app/atom.go | 22 ++++++++++++----- tsunami/app/defaultclient.go | 33 +++++++++++++++++++++----- tsunami/demo/cpuchart/app.go | 12 +++++++--- tsunami/demo/githubaction/app.go | 39 ++++++++++++++++++++++++------- tsunami/demo/pomodoro/app.go | 7 +++++- tsunami/demo/recharts/app.go | 13 ++++++++--- tsunami/demo/tabletest/app.go | 2 ++ tsunami/demo/tsunamiconfig/app.go | 5 +++- tsunami/engine/atomimpl.go | 14 ++++++++++- tsunami/engine/hooks.go | 2 +- 10 files changed, 119 insertions(+), 30 deletions(-) diff --git a/tsunami/app/atom.go b/tsunami/app/atom.go index 2800f7f65f..587e04118c 100644 --- a/tsunami/app/atom.go +++ b/tsunami/app/atom.go @@ -12,6 +12,22 @@ import ( "github.com/wavetermdev/waveterm/tsunami/util" ) +// AtomMeta provides metadata about an atom for validation and documentation +type AtomMeta struct { + Desc string // short, user-facing + Units string // "ms", "GiB", etc. + Min *float64 // optional minimum (numeric types) + Max *float64 // optional maximum (numeric types) + Enum []string // allowed values if finite set + Pattern string // regex constraint for strings +} + +// Atom[T] represents a typed atom implementation +type Atom[T any] struct { + name string + client *engine.ClientImpl +} + // logInvalidAtomSet logs an error when an atom is being set during component render func logInvalidAtomSet(atomName string) { _, file, line, ok := runtime.Caller(2) @@ -58,12 +74,6 @@ func logMutationWarning(atomName string) { } } -// Atom[T] represents a typed atom implementation -type Atom[T any] struct { - name string - client *engine.ClientImpl -} - // AtomName implements the vdom.Atom interface func (a Atom[T]) AtomName() string { return a.name diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index dde0d6f264..e2650cc733 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -16,6 +16,10 @@ func DefineComponent[P any](name string, renderFn func(props P) any) vdom.Compon return engine.DefineComponentEx(engine.GetDefaultClient(), name, renderFn) } +func FloatPtr(f float64) *float64 { + return &f +} + func SetGlobalEventHandler(handler func(event vdom.VDomEvent)) { engine.GetDefaultClient().SetGlobalEventHandler(handler) } @@ -35,30 +39,47 @@ func SendAsyncInitiation() error { return engine.GetDefaultClient().SendAsyncInitiation() } -func ConfigAtom[T any](name string, defaultValue T) Atom[T] { +func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { fullName := "$config." + name client := engine.GetDefaultClient() - atom := engine.MakeAtomImpl(defaultValue) + engineMeta := convertAppMetaToEngineMeta(meta) + atom := engine.MakeAtomImpl(defaultValue, engineMeta) client.Root.RegisterAtom(fullName, atom) return Atom[T]{name: fullName, client: client} } -func DataAtom[T any](name string, defaultValue T) Atom[T] { +func DataAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { fullName := "$data." + name client := engine.GetDefaultClient() - atom := engine.MakeAtomImpl(defaultValue) + engineMeta := convertAppMetaToEngineMeta(meta) + atom := engine.MakeAtomImpl(defaultValue, engineMeta) client.Root.RegisterAtom(fullName, atom) return Atom[T]{name: fullName, client: client} } -func SharedAtom[T any](name string, defaultValue T) Atom[T] { +func SharedAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { fullName := "$shared." + name client := engine.GetDefaultClient() - atom := engine.MakeAtomImpl(defaultValue) + engineMeta := convertAppMetaToEngineMeta(meta) + atom := engine.MakeAtomImpl(defaultValue, engineMeta) client.Root.RegisterAtom(fullName, atom) return Atom[T]{name: fullName, client: client} } +func convertAppMetaToEngineMeta(appMeta *AtomMeta) *engine.AtomMeta { + if appMeta == nil { + return nil + } + return &engine.AtomMeta{ + Description: appMeta.Desc, + Units: appMeta.Units, + Min: appMeta.Min, + Max: appMeta.Max, + Enum: appMeta.Enum, + Pattern: appMeta.Pattern, + } +} + // HandleDynFunc registers a dynamic HTTP handler function with the internal http.ServeMux. // The pattern MUST start with "/dyn/" to be valid. This allows registration of dynamic // routes that can be handled at runtime. diff --git a/tsunami/demo/cpuchart/app.go b/tsunami/demo/cpuchart/app.go index 3d6802e3ef..5631750516 100644 --- a/tsunami/demo/cpuchart/app.go +++ b/tsunami/demo/cpuchart/app.go @@ -11,8 +11,12 @@ import ( // Global atoms for config and data var ( - dataPointCountAtom = app.ConfigAtom("dataPointCount", 60) - cpuDataAtom = app.DataAtom("cpuData", func() []CPUDataPoint { + dataPointCountAtom = app.ConfigAtom("dataPointCount", 60, &app.AtomMeta{ + Desc: "Number of CPU data points to display in the chart", + Min: app.FloatPtr(10), + Max: app.FloatPtr(300), + }) + cpuDataAtom = app.DataAtom("cpuData", func() []CPUDataPoint { // Initialize with empty data points to maintain consistent chart size dataPointCount := 60 // Default value for initialization initialData := make([]CPUDataPoint, dataPointCount) @@ -24,7 +28,9 @@ var ( } } return initialData - }()) + }(), &app.AtomMeta{ + Desc: "Historical CPU usage data points for charting", + }) ) type CPUDataPoint struct { diff --git a/tsunami/demo/githubaction/app.go b/tsunami/demo/githubaction/app.go index 2a8ef1a7e2..777b08a695 100644 --- a/tsunami/demo/githubaction/app.go +++ b/tsunami/demo/githubaction/app.go @@ -17,14 +17,37 @@ import ( // Global atoms for config and data var ( - pollIntervalAtom = app.ConfigAtom("pollInterval", 5) - repositoryAtom = app.ConfigAtom("repository", "wavetermdev/waveterm") - workflowAtom = app.ConfigAtom("workflow", "build-helper.yml") - maxWorkflowRunsAtom = app.ConfigAtom("maxWorkflowRuns", 10) - workflowRunsAtom = app.DataAtom("workflowRuns", []WorkflowRun{}) - lastErrorAtom = app.DataAtom("lastError", "") - isLoadingAtom = app.DataAtom("isLoading", true) - lastRefreshTimeAtom = app.DataAtom("lastRefreshTime", time.Time{}) + pollIntervalAtom = app.ConfigAtom("pollInterval", 5, &app.AtomMeta{ + Desc: "Polling interval for GitHub API requests", + Units: "s", + Min: app.FloatPtr(1), + Max: app.FloatPtr(300), + }) + repositoryAtom = app.ConfigAtom("repository", "wavetermdev/waveterm", &app.AtomMeta{ + Desc: "GitHub repository in owner/repo format", + Pattern: `^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$`, + }) + workflowAtom = app.ConfigAtom("workflow", "build-helper.yml", &app.AtomMeta{ + Desc: "GitHub Actions workflow file name", + Pattern: `^.+\.(yml|yaml)$`, + }) + maxWorkflowRunsAtom = app.ConfigAtom("maxWorkflowRuns", 10, &app.AtomMeta{ + Desc: "Maximum number of workflow runs to fetch", + Min: app.FloatPtr(1), + Max: app.FloatPtr(100), + }) + workflowRunsAtom = app.DataAtom("workflowRuns", []WorkflowRun{}, &app.AtomMeta{ + Desc: "List of GitHub Actions workflow runs", + }) + lastErrorAtom = app.DataAtom("lastError", "", &app.AtomMeta{ + Desc: "Last error message from GitHub API", + }) + isLoadingAtom = app.DataAtom("isLoading", true, &app.AtomMeta{ + Desc: "Loading state for workflow data fetch", + }) + lastRefreshTimeAtom = app.DataAtom("lastRefreshTime", time.Time{}, &app.AtomMeta{ + Desc: "Timestamp of last successful data refresh", + }) ) type WorkflowRun struct { diff --git a/tsunami/demo/pomodoro/app.go b/tsunami/demo/pomodoro/app.go index eda19c0835..0ae0f258be 100644 --- a/tsunami/demo/pomodoro/app.go +++ b/tsunami/demo/pomodoro/app.go @@ -18,7 +18,12 @@ var ( BreakMode = Mode{Name: "Break", Duration: 5} // Data atom to expose remaining seconds to external systems - remainingSecondsAtom = app.DataAtom("remainingSeconds", WorkMode.Duration*60) + remainingSecondsAtom = app.DataAtom("remainingSeconds", WorkMode.Duration*60, &app.AtomMeta{ + Desc: "Remaining seconds in current pomodoro timer", + Units: "s", + Min: app.FloatPtr(0), + Max: app.FloatPtr(3600), + }) ) type TimerDisplayProps struct { diff --git a/tsunami/demo/recharts/app.go b/tsunami/demo/recharts/app.go index b558292d1a..810bfa7dfa 100644 --- a/tsunami/demo/recharts/app.go +++ b/tsunami/demo/recharts/app.go @@ -10,9 +10,16 @@ import ( // Global atoms for config and data var ( - chartDataAtom = app.DataAtom("chartData", generateInitialData()) - chartTypeAtom = app.ConfigAtom("chartType", "line") - isAnimatingAtom = app.SharedAtom("isAnimating", false) + chartDataAtom = app.DataAtom("chartData", generateInitialData(), &app.AtomMeta{ + Desc: "Chart data points for system metrics visualization", + }) + chartTypeAtom = app.ConfigAtom("chartType", "line", &app.AtomMeta{ + Desc: "Type of chart to display", + Enum: []string{"line", "area", "bar"}, + }) + isAnimatingAtom = app.SharedAtom("isAnimating", false, &app.AtomMeta{ + Desc: "Whether the chart is currently animating with live data", + }) ) type DataPoint struct { diff --git a/tsunami/demo/tabletest/app.go b/tsunami/demo/tabletest/app.go index cb086f5709..61aae27215 100644 --- a/tsunami/demo/tabletest/app.go +++ b/tsunami/demo/tabletest/app.go @@ -31,6 +31,8 @@ var sampleData = app.DataAtom("sampleData", []Person{ {Name: "Henry Taylor", Age: 33, Email: "henry@example.com", City: "San Diego"}, {Name: "Ivy Chen", Age: 26, Email: "ivy@example.com", City: "Dallas"}, {Name: "Jack Anderson", Age: 31, Email: "jack@example.com", City: "San Jose"}, +}, &app.AtomMeta{ + Desc: "Sample person data for table display testing", }) // The App component is the required entry point for every Tsunami application diff --git a/tsunami/demo/tsunamiconfig/app.go b/tsunami/demo/tsunamiconfig/app.go index 77730763ac..76d0591bfd 100644 --- a/tsunami/demo/tsunamiconfig/app.go +++ b/tsunami/demo/tsunamiconfig/app.go @@ -16,7 +16,10 @@ import ( // Global atoms for config var ( - serverURLAtom = app.ConfigAtom("serverURL", "") + serverURLAtom = app.ConfigAtom("serverURL", "", &app.AtomMeta{ + Desc: "Server URL for config API (can be full URL, hostname:port, or just port)", + Pattern: `^(https?://.*|[a-zA-Z0-9.-]+:\d+|\d+|[a-zA-Z0-9.-]+)$`, + }) ) type URLInputProps struct { diff --git a/tsunami/engine/atomimpl.go b/tsunami/engine/atomimpl.go index 3d9c5a4896..f7133f8f09 100644 --- a/tsunami/engine/atomimpl.go +++ b/tsunami/engine/atomimpl.go @@ -9,17 +9,29 @@ import ( "sync" ) +// AtomMeta provides metadata about an atom for validation and documentation +type AtomMeta struct { + Description string // short, user-facing + Units string // "ms", "GiB", etc. + Min *float64 // optional minimum (numeric types) + Max *float64 // optional maximum (numeric types) + Enum []string // allowed values if finite set + Pattern string // regex constraint for strings +} + type AtomImpl[T any] struct { lock *sync.Mutex val T usedBy map[string]bool // component waveid -> true + meta *AtomMeta // optional metadata } -func MakeAtomImpl[T any](initialVal T) *AtomImpl[T] { +func MakeAtomImpl[T any](initialVal T, meta *AtomMeta) *AtomImpl[T] { return &AtomImpl[T]{ lock: &sync.Mutex{}, val: initialVal, usedBy: make(map[string]bool), + meta: meta, } } diff --git a/tsunami/engine/hooks.go b/tsunami/engine/hooks.go index d4db65d8cc..179871e903 100644 --- a/tsunami/engine/hooks.go +++ b/tsunami/engine/hooks.go @@ -77,7 +77,7 @@ func UseLocal(vc *RenderContextImpl, initialVal any) string { atomName := "$local." + vc.GetCompWaveId() + "#" + strconv.Itoa(hookVal.Idx) if !hookVal.Init { hookVal.Init = true - atom := MakeAtomImpl(initialVal) + atom := MakeAtomImpl(initialVal, nil) vc.Root.RegisterAtom(atomName, atom) closedAtomName := atomName hookVal.UnmountFn = func() { From b655e3e9c924ba46019160beee9436755273101c Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 11 Sep 2025 20:59:54 -0700 Subject: [PATCH 2/8] working on jsonschema impl for atoms --- tsunami/app/defaultclient.go | 5 +- tsunami/demo/recharts/app.go | 2 +- tsunami/engine/atomimpl.go | 179 +++++++++++++++++++++++++++++++++++ tsunami/util/util.go | 58 +++++++++++- 4 files changed, 237 insertions(+), 7 deletions(-) diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index e2650cc733..f2e7aab34e 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -57,11 +57,10 @@ func DataAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { return Atom[T]{name: fullName, client: client} } -func SharedAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { +func SharedAtom[T any](name string, defaultValue T) Atom[T] { fullName := "$shared." + name client := engine.GetDefaultClient() - engineMeta := convertAppMetaToEngineMeta(meta) - atom := engine.MakeAtomImpl(defaultValue, engineMeta) + atom := engine.MakeAtomImpl(defaultValue, nil) client.Root.RegisterAtom(fullName, atom) return Atom[T]{name: fullName, client: client} } diff --git a/tsunami/demo/recharts/app.go b/tsunami/demo/recharts/app.go index 810bfa7dfa..12c2ca2ec9 100644 --- a/tsunami/demo/recharts/app.go +++ b/tsunami/demo/recharts/app.go @@ -17,7 +17,7 @@ var ( Desc: "Type of chart to display", Enum: []string{"line", "area", "bar"}, }) - isAnimatingAtom = app.SharedAtom("isAnimating", false, &app.AtomMeta{ + isAnimatingAtom = app.ConfigAtom("isAnimating", false, &app.AtomMeta{ Desc: "Whether the chart is currently animating with live data", }) ) diff --git a/tsunami/engine/atomimpl.go b/tsunami/engine/atomimpl.go index f7133f8f09..cf4ccc4f7e 100644 --- a/tsunami/engine/atomimpl.go +++ b/tsunami/engine/atomimpl.go @@ -6,7 +6,11 @@ package engine import ( "encoding/json" "fmt" + "reflect" "sync" + "time" + + "github.com/wavetermdev/waveterm/tsunami/util" ) // AtomMeta provides metadata about an atom for validation and documentation @@ -96,3 +100,178 @@ func (a *AtomImpl[T]) GetUsedBy() []string { } return keys } + +// createStructDefinition creates a JSON schema definition for a struct type +func createStructDefinition(t reflect.Type) map[string]any { + structDef := make(map[string]any) + structDef["type"] = "object" + properties := make(map[string]any) + required := make([]string, 0) + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + + // Parse JSON tag + fieldInfo, shouldInclude := util.ParseJSONTag(field) + if !shouldInclude { + continue // Skip this field + } + + // If field has "string" option, force schema type to string + if fieldInfo.AsString { + fieldSchema := map[string]any{"type": "string"} + properties[fieldInfo.FieldName] = fieldSchema + } else { + properties[fieldInfo.FieldName] = generateShallowJSONSchema(field.Type, nil) + } + + // Add to required if not a pointer and not marked as omitempty + if field.Type.Kind() != reflect.Ptr && !fieldInfo.OmitEmpty { + required = append(required, fieldInfo.FieldName) + } + } + + if len(properties) > 0 { + structDef["properties"] = properties + } + if len(required) > 0 { + structDef["required"] = required + } + + return structDef +} + +// collectStructDefs walks the type tree and adds struct definitions to defs map +func collectStructDefs(t reflect.Type, defs map[reflect.Type]any) { + switch t.Kind() { + case reflect.Slice, reflect.Array: + if t.Elem() != nil { + collectStructDefs(t.Elem(), defs) + } + case reflect.Map: + if t.Elem() != nil { + collectStructDefs(t.Elem(), defs) + } + case reflect.Struct: + // Skip if we already have this struct definition + if _, exists := defs[t]; exists { + return + } + + // Create the struct definition + structDef := createStructDefinition(t) + + // Add the definition before recursing into field types + defs[t] = structDef + + // Now recurse into field types to collect their struct definitions + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.IsExported() { + _, shouldInclude := util.ParseJSONTag(field) + if shouldInclude { + collectStructDefs(field.Type, defs) + } + } + } + case reflect.Ptr: + collectStructDefs(t.Elem(), defs) + } +} + +// annotateSchemaWithAtomMeta applies AtomMeta annotations to a JSON schema +func annotateSchemaWithAtomMeta(schema map[string]any, meta *AtomMeta) { + if meta == nil { + return + } + + if meta.Description != "" { + schema["description"] = meta.Description + } + + if meta.Units != "" { + schema["units"] = meta.Units + } + + // Add numeric constraints for number/integer types + if schema["type"] == "number" || schema["type"] == "integer" { + if meta.Min != nil { + schema["minimum"] = *meta.Min + } + if meta.Max != nil { + schema["maximum"] = *meta.Max + } + } + + // Add enum values if specified + if len(meta.Enum) > 0 { + enumValues := make([]any, len(meta.Enum)) + for i, v := range meta.Enum { + enumValues[i] = v + } + schema["enum"] = enumValues + } + + // Add pattern constraint for strings + if schema["type"] == "string" && meta.Pattern != "" { + schema["pattern"] = meta.Pattern + } +} + +// generateShallowJSONSchema creates a schema that references definitions instead of recursing +func generateShallowJSONSchema(t reflect.Type, meta *AtomMeta) map[string]any { + schema := make(map[string]any) + defer func() { + annotateSchemaWithAtomMeta(schema, meta) + }() + + // Special case for time.Time - treat as string with date-time format + if t == reflect.TypeOf(time.Time{}) { + schema["type"] = "string" + schema["format"] = "date-time" + return schema + } + + // Special case for []byte - treat as string with base64 encoding + if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 { + schema["type"] = "string" + schema["format"] = "base64" + return schema + } + + switch t.Kind() { + case reflect.String: + schema["type"] = "string" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + schema["type"] = "integer" + case reflect.Float32, reflect.Float64: + schema["type"] = "number" + case reflect.Bool: + schema["type"] = "boolean" + case reflect.Slice, reflect.Array: + schema["type"] = "array" + if t.Elem() != nil { + schema["items"] = generateShallowJSONSchema(t.Elem(), nil) + } + case reflect.Map: + schema["type"] = "object" + if t.Elem() != nil { + schema["additionalProperties"] = generateShallowJSONSchema(t.Elem(), nil) + } + case reflect.Struct: + // Reference the definition instead of recursing + schema["$ref"] = fmt.Sprintf("#/definitions/%s", t.Name()) + case reflect.Ptr: + return generateShallowJSONSchema(t.Elem(), meta) + case reflect.Interface: + schema["type"] = "object" + default: + schema["type"] = "object" + } + + return schema +} diff --git a/tsunami/util/util.go b/tsunami/util/util.go index e8d879c783..bb3cf39776 100644 --- a/tsunami/util/util.go +++ b/tsunami/util/util.go @@ -18,9 +18,10 @@ import ( // PanicHandler handles panic recovery and logging. // It can be called directly with recover() without checking for nil first. // Example usage: -// defer func() { -// util.PanicHandler("operation name", recover()) -// }() +// +// defer func() { +// util.PanicHandler("operation name", recover()) +// }() func PanicHandler(debugStr string, recoverVal any) error { if recoverVal == nil { return nil @@ -123,6 +124,7 @@ func GetTypedAtomValue[T any](rawVal any, atomName string) T { var ( jsonMarshalerT = reflect.TypeOf((*json.Marshaler)(nil)).Elem() textMarshalerT = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem() + timeType = reflect.TypeOf(time.Time{}) ) func implementsJSON(t reflect.Type) bool { @@ -163,6 +165,11 @@ func validateAtomTypeRecursive(t reflect.Type, seen map[reflect.Type]bool, atomN return nil } + // Allow time.Time explicitly + if t == timeType { + return nil + } + switch t.Kind() { case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, @@ -231,3 +238,48 @@ func validateAtomTypeRecursive(t reflect.Type, seen map[reflect.Type]bool, atomN return makeAtomError(atomName, parentName, fmt.Sprintf("unsupported type %s", t.Kind())) } } + +type JsonFieldInfo struct { + FieldName string + OmitEmpty bool + AsString bool + Options []string +} + +func ParseJSONTag(field reflect.StructField) (JsonFieldInfo, bool) { + tag := field.Tag.Get("json") + + // Ignore field + if tag == "-" { + return JsonFieldInfo{}, false + } + + name := field.Name + var opts []string + var omitEmpty, asString bool + + if tag != "" { + parts := strings.Split(tag, ",") + if parts[0] != "" { + name = parts[0] + } + if len(parts) > 1 { + opts = parts[1:] + for _, opt := range opts { + switch opt { + case "omitempty": + omitEmpty = true + case "string": + asString = true + } + } + } + } + + return JsonFieldInfo{ + FieldName: name, + OmitEmpty: omitEmpty, + AsString: asString, + Options: opts, + }, true +} From 37a29cb16ed3e078188fab4bf1b13780471cc89d Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 11 Sep 2025 21:01:56 -0700 Subject: [PATCH 3/8] move schema stuff to new file --- tsunami/engine/atomimpl.go | 179 ----------------------------------- tsunami/engine/schema.go | 187 +++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 179 deletions(-) create mode 100644 tsunami/engine/schema.go diff --git a/tsunami/engine/atomimpl.go b/tsunami/engine/atomimpl.go index cf4ccc4f7e..f7133f8f09 100644 --- a/tsunami/engine/atomimpl.go +++ b/tsunami/engine/atomimpl.go @@ -6,11 +6,7 @@ package engine import ( "encoding/json" "fmt" - "reflect" "sync" - "time" - - "github.com/wavetermdev/waveterm/tsunami/util" ) // AtomMeta provides metadata about an atom for validation and documentation @@ -100,178 +96,3 @@ func (a *AtomImpl[T]) GetUsedBy() []string { } return keys } - -// createStructDefinition creates a JSON schema definition for a struct type -func createStructDefinition(t reflect.Type) map[string]any { - structDef := make(map[string]any) - structDef["type"] = "object" - properties := make(map[string]any) - required := make([]string, 0) - - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - if !field.IsExported() { - continue - } - - // Parse JSON tag - fieldInfo, shouldInclude := util.ParseJSONTag(field) - if !shouldInclude { - continue // Skip this field - } - - // If field has "string" option, force schema type to string - if fieldInfo.AsString { - fieldSchema := map[string]any{"type": "string"} - properties[fieldInfo.FieldName] = fieldSchema - } else { - properties[fieldInfo.FieldName] = generateShallowJSONSchema(field.Type, nil) - } - - // Add to required if not a pointer and not marked as omitempty - if field.Type.Kind() != reflect.Ptr && !fieldInfo.OmitEmpty { - required = append(required, fieldInfo.FieldName) - } - } - - if len(properties) > 0 { - structDef["properties"] = properties - } - if len(required) > 0 { - structDef["required"] = required - } - - return structDef -} - -// collectStructDefs walks the type tree and adds struct definitions to defs map -func collectStructDefs(t reflect.Type, defs map[reflect.Type]any) { - switch t.Kind() { - case reflect.Slice, reflect.Array: - if t.Elem() != nil { - collectStructDefs(t.Elem(), defs) - } - case reflect.Map: - if t.Elem() != nil { - collectStructDefs(t.Elem(), defs) - } - case reflect.Struct: - // Skip if we already have this struct definition - if _, exists := defs[t]; exists { - return - } - - // Create the struct definition - structDef := createStructDefinition(t) - - // Add the definition before recursing into field types - defs[t] = structDef - - // Now recurse into field types to collect their struct definitions - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - if field.IsExported() { - _, shouldInclude := util.ParseJSONTag(field) - if shouldInclude { - collectStructDefs(field.Type, defs) - } - } - } - case reflect.Ptr: - collectStructDefs(t.Elem(), defs) - } -} - -// annotateSchemaWithAtomMeta applies AtomMeta annotations to a JSON schema -func annotateSchemaWithAtomMeta(schema map[string]any, meta *AtomMeta) { - if meta == nil { - return - } - - if meta.Description != "" { - schema["description"] = meta.Description - } - - if meta.Units != "" { - schema["units"] = meta.Units - } - - // Add numeric constraints for number/integer types - if schema["type"] == "number" || schema["type"] == "integer" { - if meta.Min != nil { - schema["minimum"] = *meta.Min - } - if meta.Max != nil { - schema["maximum"] = *meta.Max - } - } - - // Add enum values if specified - if len(meta.Enum) > 0 { - enumValues := make([]any, len(meta.Enum)) - for i, v := range meta.Enum { - enumValues[i] = v - } - schema["enum"] = enumValues - } - - // Add pattern constraint for strings - if schema["type"] == "string" && meta.Pattern != "" { - schema["pattern"] = meta.Pattern - } -} - -// generateShallowJSONSchema creates a schema that references definitions instead of recursing -func generateShallowJSONSchema(t reflect.Type, meta *AtomMeta) map[string]any { - schema := make(map[string]any) - defer func() { - annotateSchemaWithAtomMeta(schema, meta) - }() - - // Special case for time.Time - treat as string with date-time format - if t == reflect.TypeOf(time.Time{}) { - schema["type"] = "string" - schema["format"] = "date-time" - return schema - } - - // Special case for []byte - treat as string with base64 encoding - if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 { - schema["type"] = "string" - schema["format"] = "base64" - return schema - } - - switch t.Kind() { - case reflect.String: - schema["type"] = "string" - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - schema["type"] = "integer" - case reflect.Float32, reflect.Float64: - schema["type"] = "number" - case reflect.Bool: - schema["type"] = "boolean" - case reflect.Slice, reflect.Array: - schema["type"] = "array" - if t.Elem() != nil { - schema["items"] = generateShallowJSONSchema(t.Elem(), nil) - } - case reflect.Map: - schema["type"] = "object" - if t.Elem() != nil { - schema["additionalProperties"] = generateShallowJSONSchema(t.Elem(), nil) - } - case reflect.Struct: - // Reference the definition instead of recursing - schema["$ref"] = fmt.Sprintf("#/definitions/%s", t.Name()) - case reflect.Ptr: - return generateShallowJSONSchema(t.Elem(), meta) - case reflect.Interface: - schema["type"] = "object" - default: - schema["type"] = "object" - } - - return schema -} diff --git a/tsunami/engine/schema.go b/tsunami/engine/schema.go new file mode 100644 index 0000000000..f67887f249 --- /dev/null +++ b/tsunami/engine/schema.go @@ -0,0 +1,187 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package engine + +import ( + "fmt" + "reflect" + "time" + + "github.com/wavetermdev/waveterm/tsunami/util" +) + +// createStructDefinition creates a JSON schema definition for a struct type +func createStructDefinition(t reflect.Type) map[string]any { + structDef := make(map[string]any) + structDef["type"] = "object" + properties := make(map[string]any) + required := make([]string, 0) + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + + // Parse JSON tag + fieldInfo, shouldInclude := util.ParseJSONTag(field) + if !shouldInclude { + continue // Skip this field + } + + // If field has "string" option, force schema type to string + if fieldInfo.AsString { + fieldSchema := map[string]any{"type": "string"} + properties[fieldInfo.FieldName] = fieldSchema + } else { + properties[fieldInfo.FieldName] = generateShallowJSONSchema(field.Type, nil) + } + + // Add to required if not a pointer and not marked as omitempty + if field.Type.Kind() != reflect.Ptr && !fieldInfo.OmitEmpty { + required = append(required, fieldInfo.FieldName) + } + } + + if len(properties) > 0 { + structDef["properties"] = properties + } + if len(required) > 0 { + structDef["required"] = required + } + + return structDef +} + +// collectStructDefs walks the type tree and adds struct definitions to defs map +func collectStructDefs(t reflect.Type, defs map[reflect.Type]any) { + switch t.Kind() { + case reflect.Slice, reflect.Array: + if t.Elem() != nil { + collectStructDefs(t.Elem(), defs) + } + case reflect.Map: + if t.Elem() != nil { + collectStructDefs(t.Elem(), defs) + } + case reflect.Struct: + // Skip if we already have this struct definition + if _, exists := defs[t]; exists { + return + } + + // Create the struct definition + structDef := createStructDefinition(t) + + // Add the definition before recursing into field types + defs[t] = structDef + + // Now recurse into field types to collect their struct definitions + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.IsExported() { + _, shouldInclude := util.ParseJSONTag(field) + if shouldInclude { + collectStructDefs(field.Type, defs) + } + } + } + case reflect.Ptr: + collectStructDefs(t.Elem(), defs) + } +} + +// annotateSchemaWithAtomMeta applies AtomMeta annotations to a JSON schema +func annotateSchemaWithAtomMeta(schema map[string]any, meta *AtomMeta) { + if meta == nil { + return + } + + if meta.Description != "" { + schema["description"] = meta.Description + } + + if meta.Units != "" { + schema["units"] = meta.Units + } + + // Add numeric constraints for number/integer types + if schema["type"] == "number" || schema["type"] == "integer" { + if meta.Min != nil { + schema["minimum"] = *meta.Min + } + if meta.Max != nil { + schema["maximum"] = *meta.Max + } + } + + // Add enum values if specified + if len(meta.Enum) > 0 { + enumValues := make([]any, len(meta.Enum)) + for i, v := range meta.Enum { + enumValues[i] = v + } + schema["enum"] = enumValues + } + + // Add pattern constraint for strings + if schema["type"] == "string" && meta.Pattern != "" { + schema["pattern"] = meta.Pattern + } +} + +// generateShallowJSONSchema creates a schema that references definitions instead of recursing +func generateShallowJSONSchema(t reflect.Type, meta *AtomMeta) map[string]any { + schema := make(map[string]any) + defer func() { + annotateSchemaWithAtomMeta(schema, meta) + }() + + // Special case for time.Time - treat as string with date-time format + if t == reflect.TypeOf(time.Time{}) { + schema["type"] = "string" + schema["format"] = "date-time" + return schema + } + + // Special case for []byte - treat as string with base64 encoding + if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 { + schema["type"] = "string" + schema["format"] = "base64" + return schema + } + + switch t.Kind() { + case reflect.String: + schema["type"] = "string" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + schema["type"] = "integer" + case reflect.Float32, reflect.Float64: + schema["type"] = "number" + case reflect.Bool: + schema["type"] = "boolean" + case reflect.Slice, reflect.Array: + schema["type"] = "array" + if t.Elem() != nil { + schema["items"] = generateShallowJSONSchema(t.Elem(), nil) + } + case reflect.Map: + schema["type"] = "object" + if t.Elem() != nil { + schema["additionalProperties"] = generateShallowJSONSchema(t.Elem(), nil) + } + case reflect.Struct: + // Reference the definition instead of recursing + schema["$ref"] = fmt.Sprintf("#/definitions/%s", t.Name()) + case reflect.Ptr: + return generateShallowJSONSchema(t.Elem(), meta) + case reflect.Interface: + schema["type"] = "object" + default: + schema["type"] = "object" + } + + return schema +} From 3fcf2c7e8162e140eb7302680ec168eba98189a6 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 11 Sep 2025 21:38:27 -0700 Subject: [PATCH 4/8] update system prompt for AtomMeta/schema. implement tags in schema.go, update examples to use app.Ptr() --- tsunami/app/defaultclient.go | 4 +- tsunami/demo/cpuchart/app.go | 4 +- tsunami/demo/githubaction/app.go | 10 +- tsunami/demo/pomodoro/app.go | 9 +- tsunami/engine/schema.go | 48 +++++++++- tsunami/prompts/system.md | 157 ++++++++++++++++++++----------- 6 files changed, 158 insertions(+), 74 deletions(-) diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index f2e7aab34e..17dbfbe84f 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -16,8 +16,8 @@ func DefineComponent[P any](name string, renderFn func(props P) any) vdom.Compon return engine.DefineComponentEx(engine.GetDefaultClient(), name, renderFn) } -func FloatPtr(f float64) *float64 { - return &f +func Ptr[T any](v T) *T { + return &v } func SetGlobalEventHandler(handler func(event vdom.VDomEvent)) { diff --git a/tsunami/demo/cpuchart/app.go b/tsunami/demo/cpuchart/app.go index 5631750516..d7c735441a 100644 --- a/tsunami/demo/cpuchart/app.go +++ b/tsunami/demo/cpuchart/app.go @@ -13,8 +13,8 @@ import ( var ( dataPointCountAtom = app.ConfigAtom("dataPointCount", 60, &app.AtomMeta{ Desc: "Number of CPU data points to display in the chart", - Min: app.FloatPtr(10), - Max: app.FloatPtr(300), + Min: app.Ptr(10.0), + Max: app.Ptr(300.0), }) cpuDataAtom = app.DataAtom("cpuData", func() []CPUDataPoint { // Initialize with empty data points to maintain consistent chart size diff --git a/tsunami/demo/githubaction/app.go b/tsunami/demo/githubaction/app.go index 777b08a695..a7a3ce3812 100644 --- a/tsunami/demo/githubaction/app.go +++ b/tsunami/demo/githubaction/app.go @@ -18,10 +18,10 @@ import ( // Global atoms for config and data var ( pollIntervalAtom = app.ConfigAtom("pollInterval", 5, &app.AtomMeta{ - Desc: "Polling interval for GitHub API requests", + Desc: "Polling interval for GitHub API requests", Units: "s", - Min: app.FloatPtr(1), - Max: app.FloatPtr(300), + Min: app.Ptr(1.0), + Max: app.Ptr(300.0), }) repositoryAtom = app.ConfigAtom("repository", "wavetermdev/waveterm", &app.AtomMeta{ Desc: "GitHub repository in owner/repo format", @@ -33,8 +33,8 @@ var ( }) maxWorkflowRunsAtom = app.ConfigAtom("maxWorkflowRuns", 10, &app.AtomMeta{ Desc: "Maximum number of workflow runs to fetch", - Min: app.FloatPtr(1), - Max: app.FloatPtr(100), + Min: app.Ptr(1.0), + Max: app.Ptr(100.0), }) workflowRunsAtom = app.DataAtom("workflowRuns", []WorkflowRun{}, &app.AtomMeta{ Desc: "List of GitHub Actions workflow runs", diff --git a/tsunami/demo/pomodoro/app.go b/tsunami/demo/pomodoro/app.go index 0ae0f258be..12926edd07 100644 --- a/tsunami/demo/pomodoro/app.go +++ b/tsunami/demo/pomodoro/app.go @@ -21,8 +21,8 @@ var ( remainingSecondsAtom = app.DataAtom("remainingSeconds", WorkMode.Duration*60, &app.AtomMeta{ Desc: "Remaining seconds in current pomodoro timer", Units: "s", - Min: app.FloatPtr(0), - Max: app.FloatPtr(3600), + Min: app.Ptr(0.0), + Max: app.Ptr(3600.0), }) ) @@ -39,7 +39,6 @@ type ControlButtonsProps struct { OnMode func(int) `json:"onMode"` } - var TimerDisplay = app.DefineComponent("TimerDisplay", func(props TimerDisplayProps) any { minutes := props.RemainingSeconds / 60 @@ -156,7 +155,7 @@ var App = app.DefineComponent("App", if !isRunning.Get() { return } - + // Calculate remaining time and update remainingSeconds elapsed := time.Since(startTime.Current) remaining := totalDuration.Current - elapsed @@ -195,7 +194,7 @@ var App = app.DefineComponent("App", ), TimerDisplay(TimerDisplayProps{ RemainingSeconds: remainingSecondsAtom.Get(), - Mode: mode.Get(), + Mode: mode.Get(), }), ControlButtons(ControlButtonsProps{ IsRunning: isRunning.Get(), diff --git a/tsunami/engine/schema.go b/tsunami/engine/schema.go index f67887f249..b08a1b8f1d 100644 --- a/tsunami/engine/schema.go +++ b/tsunami/engine/schema.go @@ -6,6 +6,8 @@ package engine import ( "fmt" "reflect" + "strconv" + "strings" "time" "github.com/wavetermdev/waveterm/tsunami/util" @@ -31,13 +33,53 @@ func createStructDefinition(t reflect.Type) map[string]any { } // If field has "string" option, force schema type to string + var fieldSchema map[string]any if fieldInfo.AsString { - fieldSchema := map[string]any{"type": "string"} - properties[fieldInfo.FieldName] = fieldSchema + fieldSchema = map[string]any{"type": "string"} } else { - properties[fieldInfo.FieldName] = generateShallowJSONSchema(field.Type, nil) + fieldSchema = generateShallowJSONSchema(field.Type, nil) } + // Add description from "desc" tag if present + if desc := field.Tag.Get("desc"); desc != "" { + fieldSchema["description"] = desc + } + + // Add enum values from "enum" tag if present + if enumTag := field.Tag.Get("enum"); enumTag != "" { + enumValues := make([]any, 0) + for _, val := range strings.Split(enumTag, ",") { + trimmed := strings.TrimSpace(val) + if trimmed != "" { + enumValues = append(enumValues, trimmed) + } + } + if len(enumValues) > 0 { + fieldSchema["enum"] = enumValues + } + } + + // Add units from "units" tag if present + if units := field.Tag.Get("units"); units != "" { + fieldSchema["units"] = units + } + + // Add min/max constraints for numeric types + if fieldSchema["type"] == "number" || fieldSchema["type"] == "integer" { + if minTag := field.Tag.Get("min"); minTag != "" { + if minVal, err := strconv.ParseFloat(minTag, 64); err == nil { + fieldSchema["minimum"] = minVal + } + } + if maxTag := field.Tag.Get("max"); maxTag != "" { + if maxVal, err := strconv.ParseFloat(maxTag, 64); err == nil { + fieldSchema["maximum"] = maxVal + } + } + } + + properties[fieldInfo.FieldName] = fieldSchema + // Add to required if not a pointer and not marked as omitempty if field.Type.Kind() != reflect.Ptr && !fieldInfo.OmitEmpty { required = append(required, fieldInfo.FieldName) diff --git a/tsunami/prompts/system.md b/tsunami/prompts/system.md index e07212ea77..eed2695553 100644 --- a/tsunami/prompts/system.md +++ b/tsunami/prompts/system.md @@ -454,6 +454,21 @@ var MyComponent = app.DefineComponent("MyComponent", func(_ struct{}) any { For state shared across components or accessible to external systems, declare global atoms as package variables: +#### AtomMeta for External Integration + +ConfigAtom and DataAtom require an AtomMeta parameter to provide schema information for external tools and AI agents. SharedAtom does not use AtomMeta since it's only for internal state sharing. + +```go +type AtomMeta struct { + Desc string // Short, user-facing description + Units string // Units of measurement: "ms", "px", "GiB", etc. Leave blank for counts and unitless values + Min *float64 // Optional minimum value (numeric types only) + Max *float64 // Optional maximum value (numeric types only) + Enum []string // Allowed values if finite set + Pattern string // Regex constraint for strings +} +``` + #### Declaring Global Atoms ```go @@ -464,41 +479,37 @@ var ( userPrefs = app.SharedAtom("userPrefs", UserPreferences{}) // ConfigAtom - Configuration that external systems can read/write - theme = app.ConfigAtom("theme", "dark") - apiKey = app.ConfigAtom("apiKey", "") - maxRetries = app.ConfigAtom("maxRetries", 3) + theme = app.ConfigAtom("theme", "dark", &AtomMeta{ + Desc: "UI theme preference", + Enum: []string{"light", "dark"}, + }) + apiKey = app.ConfigAtom("apiKey", "", &AtomMeta{ + Desc: "Authentication key for external services", + Pattern: "^[A-Za-z0-9]{32}$", + }) + maxRetries = app.ConfigAtom("maxRetries", 3, &AtomMeta{ + Desc: "Maximum retry attempts for failed requests", + Min: app.Ptr(0.0), + Max: app.Ptr(10.0), + }) // DataAtom - Application data that external systems can read - currentUser = app.DataAtom("currentUser", UserStats{}) - lastPollResult = app.DataAtom("lastPoll", APIResult{}) + currentUser = app.DataAtom("currentUser", UserStats{}, &AtomMeta{ + Desc: "Current user statistics and profile data", + }) + lastPollResult = app.DataAtom("lastPoll", APIResult{}, &AtomMeta{ + Desc: "Result from the most recent API polling operation", + }) ) ``` -#### Using Global Atoms - -Global atoms work exactly like local atoms - same Get/Set interface: +- `app.Ptr(value)` - Helper to create pointers for Min/Max fields. Remember to use float64 literals like `app.Ptr(10.0)` since Min/Max expect \*float64. -```go -var MyComponent = app.DefineComponent("MyComponent", func(_ struct{}) any { - // Reading atom values (registers render dependency) - loading := isLoading.Get() - currentTheme := theme.Get() - user := currentUser.Get() - - // Setting atom values (only in event handlers) - handleToggle := func() { - isLoading.Set(true) // Direct value setting - } +AtomMeta provides top-level constraints for the atom value. For complex struct types, use struct tags on individual fields (covered in Schema Generation section). - handleRetry := func() { - maxRetries.SetFn(func(current int) int { - return current + 1 // Functional update - }) - } +#### Using Global Atoms - return vdom.H("div", nil, "Loading: ", loading) -}) -``` +Global atoms work exactly like local atoms - same Get/Set/SetFn interface. #### Global Atom Types @@ -527,9 +538,37 @@ ConfigAtom and DataAtom automatically create REST endpoints: - `GET /api/config` - Returns all config atom values - `POST /api/config` - Updates (merges) config atom values - `GET /api/data` - Returns all data atom values +- `GET /api/schema` - Returns JSON schema information for the /api/config and /api/data endpoints based on AtomMeta and type reflection information This makes Tsunami applications naturally suitable for integration with external tools, monitoring systems, and AI agents that need to inspect or configure the application. +#### Schema Generation for External Tools + +When using ConfigAtom and DataAtom, you can provide schema metadata to help external AI tools understand your atom structure. Use the optional AtomMeta parameter and struct tags for detailed field schemas: + +```go +type UserPrefs struct { + Theme string `json:"theme" desc:"UI theme preference" enum:"light,dark"` + FontSize int `json:"fontSize" desc:"Font size in pixels" units:"px" min:"8" max:"32"` + APIEndpoint string `json:"apiEndpoint" desc:"API base URL" pattern:"^https?://.*"` +} + +userPrefs := app.ConfigAtom("userPrefs", UserPrefs{}, &AtomMeta{ + Desc: "User interface and behavior preferences", +}) +``` + +**Supported schema tags:** + +- `desc:"..."` - Human-readable description of the field +- `units:"..."` - Units of measurement (ms, px, MB, GB, etc.) +- `min:"123"` - Minimum value for numeric types (parsed as a float) +- `max:"456"` - Maximum value for numeric types (parsed as a float) +- `enum:"val1,val2,val3"` - Comma-separated list of allowed string values +- `pattern:"regex"` - Regular expression for string validation + +For complex validation rules or special cases, document them in the AtomMeta description (e.g., "Note: 'retryDelays' must contain exactly 3 values in ascending order"). + ## Component Code Conventions Tsunami follows specific patterns that make code predictable for both developers and AI code generation. Following these conventions ensures consistent, maintainable code and prevents common bugs. @@ -1191,6 +1230,14 @@ type Todo struct { Completed bool `json:"completed"` } +// Global state using DataAtom for external integration +var todosAtom = app.DataAtom("todos", []Todo{ + {Id: 1, Text: "Learn Tsunami", Completed: false}, + {Id: 2, Text: "Build an app", Completed: false}, +}, &app.AtomMeta{ + Desc: "List of todo items with completion status", +}) + type TodoItemProps struct { Todo Todo `json:"todo"` OnToggle func() `json:"onToggle"` @@ -1220,59 +1267,55 @@ var TodoItem = app.DefineComponent("TodoItem", func(props TodoItemProps) any { // Root component must be named "App" var App = app.DefineComponent("App", func(_ struct{}) any { - // UseLocal returns Atom[T] with Get() and Set() methods - todos := app.UseLocal([]Todo{ - {Id: 1, Text: "Learn Tsunami", Completed: false}, - {Id: 2, Text: "Build an app", Completed: false}, - }) - nextId := app.UseLocal(3) - inputText := app.UseLocal("") + // Local state for form and ID management + nextIdAtom := app.UseLocal(3) + inputTextAtom := app.UseLocal("") // Event handlers addTodo := func() { - currentInput := inputText.Get() + currentInput := inputTextAtom.Get() if currentInput == "" { return } - currentTodos := todos.Get() - currentNextId := nextId.Get() + currentTodos := todosAtom.Get() + currentNextId := nextIdAtom.Get() - todos.Set(append(currentTodos, Todo{ + todosAtom.Set(append(currentTodos, Todo{ Id: currentNextId, Text: currentInput, Completed: false, })) - nextId.Set(currentNextId + 1) - inputText.Set("") + nextIdAtom.Set(currentNextId + 1) + inputTextAtom.Set("") } - toggleTodo := func(id int) { - todos.SetFn(func(current []Todo) []Todo { - // SetFn automatically deep copies current value - for i := range current { - if current[i].Id == id { - current[i].Completed = !current[i].Completed - break - } - } - return current - }) - } + toggleTodo := func(id int) { + todosAtom.SetFn(func(current []Todo) []Todo { + // SetFn automatically deep copies current value + for i := range current { + if current[i].Id == id { + current[i].Completed = !current[i].Completed + break + } + } + return current + }) + } deleteTodo := func(id int) { - currentTodos := todos.Get() + currentTodos := todosAtom.Get() newTodos := make([]Todo, 0) for _, todo := range currentTodos { if todo.Id != id { newTodos = append(newTodos, todo) } } - todos.Set(newTodos) + todosAtom.Set(newTodos) } // Read atom values in render code - todoList := todos.Get() - currentInput := inputText.Get() + todoList := todosAtom.Get() + currentInput := inputTextAtom.Get() return vdom.H("div", map[string]any{ "className": "max-w-[500px] m-5 font-sans", @@ -1290,7 +1333,7 @@ var App = app.DefineComponent("App", func(_ struct{}) any { "placeholder": "Add new item...", "value": currentInput, "onChange": func(e vdom.VDomEvent) { - inputText.Set(e.TargetValue) + inputTextAtom.Set(e.TargetValue) }, }), vdom.H("button", map[string]any{ From 425901802dd04e0d0df07081c8c30531b9110183 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 11 Sep 2025 22:09:24 -0700 Subject: [PATCH 5/8] generate /api/schemas from atom definitions --- tsunami/engine/atomimpl.go | 6 +++ tsunami/engine/rootelem.go | 34 +++++++++------ tsunami/engine/schema.go | 75 ++++++++++++++++++++++++++++++++ tsunami/engine/serverhandlers.go | 29 ++++++++++++ tsunami/prompts/system.md | 2 +- 5 files changed, 131 insertions(+), 15 deletions(-) diff --git a/tsunami/engine/atomimpl.go b/tsunami/engine/atomimpl.go index f7133f8f09..c8cdcbd729 100644 --- a/tsunami/engine/atomimpl.go +++ b/tsunami/engine/atomimpl.go @@ -96,3 +96,9 @@ func (a *AtomImpl[T]) GetUsedBy() []string { } return keys } + +func (a *AtomImpl[T]) GetMeta() *AtomMeta { + a.lock.Lock() + defer a.lock.Unlock() + return a.meta +} diff --git a/tsunami/engine/rootelem.go b/tsunami/engine/rootelem.go index 15ed18442f..a9d42db773 100644 --- a/tsunami/engine/rootelem.go +++ b/tsunami/engine/rootelem.go @@ -29,6 +29,7 @@ type genAtom interface { SetVal(any) error SetUsedBy(string, bool) GetUsedBy() []string + GetMeta() *AtomMeta } type RootElem struct { @@ -82,30 +83,35 @@ func (r *RootElem) addEffectWork(id string, effectIndex int, compTag string) { r.EffectWorkQueue = append(r.EffectWorkQueue, &EffectWorkElem{WaveId: id, EffectIndex: effectIndex, CompTag: compTag}) } -func (r *RootElem) GetDataMap() map[string]any { +// getAtomsByPrefix extracts all atoms that match the given prefix from RootElem +func (r *RootElem) getAtomsByPrefix(prefix string) map[string]genAtom { r.atomLock.Lock() defer r.atomLock.Unlock() - - result := make(map[string]any) + + result := make(map[string]genAtom) for atomName, atom := range r.Atoms { - if strings.HasPrefix(atomName, "$data.") { - strippedName := strings.TrimPrefix(atomName, "$data.") - result[strippedName] = atom.GetVal() + if strings.HasPrefix(atomName, prefix) { + strippedName := strings.TrimPrefix(atomName, prefix) + result[strippedName] = atom } } return result } -func (r *RootElem) GetConfigMap() map[string]any { - r.atomLock.Lock() - defer r.atomLock.Unlock() +func (r *RootElem) GetDataMap() map[string]any { + atoms := r.getAtomsByPrefix("$data.") + result := make(map[string]any) + for name, atom := range atoms { + result[name] = atom.GetVal() + } + return result +} +func (r *RootElem) GetConfigMap() map[string]any { + atoms := r.getAtomsByPrefix("$config.") result := make(map[string]any) - for atomName, atom := range r.Atoms { - if strings.HasPrefix(atomName, "$config.") { - strippedName := strings.TrimPrefix(atomName, "$config.") - result[strippedName] = atom.GetVal() - } + for name, atom := range atoms { + result[name] = atom.GetVal() } return result } diff --git a/tsunami/engine/schema.go b/tsunami/engine/schema.go index b08a1b8f1d..f855b235f8 100644 --- a/tsunami/engine/schema.go +++ b/tsunami/engine/schema.go @@ -108,6 +108,11 @@ func collectStructDefs(t reflect.Type, defs map[reflect.Type]any) { collectStructDefs(t.Elem(), defs) } case reflect.Struct: + // Skip time.Time since we handle it specially + if t == reflect.TypeOf(time.Time{}) { + return + } + // Skip if we already have this struct definition if _, exists := defs[t]; exists { return @@ -227,3 +232,73 @@ func generateShallowJSONSchema(t reflect.Type, meta *AtomMeta) map[string]any { return schema } + + + +// getAtomValueType returns the reflect.Type of the atom's value +func getAtomValueType(atom genAtom) reflect.Type { + val := atom.GetVal() + if val == nil { + return nil + } + return reflect.TypeOf(val) +} + +// getAtomMeta extracts AtomMeta from the atom +func getAtomMeta(atom genAtom) *AtomMeta { + return atom.GetMeta() +} + +// generateSchemaFromAtoms generates a JSON schema from a map of atoms +func generateSchemaFromAtoms(atoms map[string]genAtom, title, description string) map[string]any { + // Collect all struct definitions + defs := make(map[reflect.Type]any) + for _, atom := range atoms { + atomType := getAtomValueType(atom) + if atomType != nil { + collectStructDefs(atomType, defs) + } + } + + // Generate properties for each atom + properties := make(map[string]any) + for atomName, atom := range atoms { + atomType := getAtomValueType(atom) + if atomType != nil { + atomMeta := getAtomMeta(atom) + properties[atomName] = generateShallowJSONSchema(atomType, atomMeta) + } + } + + // Build the final schema + schema := map[string]any{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "title": title, + "description": description, + "properties": properties, + } + + // Add definitions if any + if len(defs) > 0 { + definitions := make(map[string]any) + for t, def := range defs { + definitions[t.Name()] = def + } + schema["definitions"] = definitions + } + + return schema +} + +// GenerateConfigSchema generates a JSON schema for all config atoms +func GenerateConfigSchema(root *RootElem) map[string]any { + configAtoms := root.getAtomsByPrefix("$config.") + return generateSchemaFromAtoms(configAtoms, "Application Configuration", "Application configuration settings") +} + +// GenerateDataSchema generates a JSON schema for all data atoms +func GenerateDataSchema(root *RootElem) map[string]any { + dataAtoms := root.getAtomsByPrefix("$data.") + return generateSchemaFromAtoms(dataAtoms, "Application Data", "Application data schema") +} diff --git a/tsunami/engine/serverhandlers.go b/tsunami/engine/serverhandlers.go index 05b8a38364..0442c51f82 100644 --- a/tsunami/engine/serverhandlers.go +++ b/tsunami/engine/serverhandlers.go @@ -48,6 +48,7 @@ func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) { mux.HandleFunc("/api/updates", h.handleSSE) mux.HandleFunc("/api/data", h.handleData) mux.HandleFunc("/api/config", h.handleConfig) + mux.HandleFunc("/api/schemas", h.handleSchemas) mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile)) mux.HandleFunc("/dyn/", h.handleDynContent) @@ -261,6 +262,34 @@ func (h *httpHandlers) handleConfigPost(w http.ResponseWriter, r *http.Request) json.NewEncoder(w).Encode(response) } +func (h *httpHandlers) handleSchemas(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleSchemas", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + configSchema := GenerateConfigSchema(h.Client.Root) + dataSchema := GenerateDataSchema(h.Client.Root) + + result := map[string]any{ + "config": configSchema, + "data": dataSchema, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(result); err != nil { + log.Printf("failed to encode schemas response: %v", err) + http.Error(w, "failed to encode response", http.StatusInternalServerError) + } +} + func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleDynContent", recover()) diff --git a/tsunami/prompts/system.md b/tsunami/prompts/system.md index eed2695553..7ca32fd622 100644 --- a/tsunami/prompts/system.md +++ b/tsunami/prompts/system.md @@ -538,7 +538,7 @@ ConfigAtom and DataAtom automatically create REST endpoints: - `GET /api/config` - Returns all config atom values - `POST /api/config` - Updates (merges) config atom values - `GET /api/data` - Returns all data atom values -- `GET /api/schema` - Returns JSON schema information for the /api/config and /api/data endpoints based on AtomMeta and type reflection information +- `GET /api/schemas` - Returns JSON schema information for the /api/config and /api/data endpoints based on AtomMeta and type reflection information This makes Tsunami applications naturally suitable for integration with external tools, monitoring systems, and AI agents that need to inspect or configure the application. From 4fba48f0b5a91dc16c6569a6cd9c1bbfdcbfde41 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 11 Sep 2025 22:43:55 -0700 Subject: [PATCH 6/8] lots of small updates to clean up code --- tsunami/demo/cpuchart/app.go | 10 +++++----- tsunami/demo/githubaction/app.go | 2 +- tsunami/engine/atomimpl.go | 6 ++++++ tsunami/engine/rootelem.go | 1 + tsunami/engine/schema.go | 33 +++++++++++++++----------------- tsunami/engine/serverhandlers.go | 19 ++++++++++++++++-- tsunami/prompts/system.md | 4 ++-- 7 files changed, 47 insertions(+), 28 deletions(-) diff --git a/tsunami/demo/cpuchart/app.go b/tsunami/demo/cpuchart/app.go index d7c735441a..215cfb5d15 100644 --- a/tsunami/demo/cpuchart/app.go +++ b/tsunami/demo/cpuchart/app.go @@ -34,9 +34,9 @@ var ( ) type CPUDataPoint struct { - Time int64 `json:"time"` // Unix timestamp in seconds - CPUUsage *float64 `json:"cpuUsage"` // CPU usage percentage (nil for empty slots) - Timestamp string `json:"timestamp"` // Human readable timestamp + Time int64 `json:"time" desc:"Unix timestamp (seconds since epoch)" units:"s"` + CPUUsage *float64 `json:"cpuUsage" desc:"CPU usage percentage" units:"%" min:"0" max:"100"` + Timestamp string `json:"timestamp" desc:"Human-readable HH:MM:SS"` } type StatsPanelProps struct { @@ -213,7 +213,7 @@ var App = app.DefineComponent("App", func(_ struct{}) any { }, "Real-Time CPU Usage Monitor"), vdom.H("p", map[string]any{ "className": "text-gray-400", - }, "Live CPU usage data collected using gopsutil, displaying 60 seconds of history"), + }, "Live CPU usage data collected using gopsutil, displaying ", dataPointCount, " seconds of history"), ), // Controls @@ -340,7 +340,7 @@ var App = app.DefineComponent("App", func(_ struct{}) any { vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), - "Rolling window of 60 seconds of historical data", + "Rolling window of ", dataPointCount, " seconds of historical data", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", diff --git a/tsunami/demo/githubaction/app.go b/tsunami/demo/githubaction/app.go index a7a3ce3812..0b54d43a0f 100644 --- a/tsunami/demo/githubaction/app.go +++ b/tsunami/demo/githubaction/app.go @@ -411,7 +411,7 @@ var App = app.DefineComponent("App", vdom.H("span", map[string]any{ "className": "text-blue-400 mt-1", }, "•"), - "Polls GitHub API every 5 seconds for real-time updates", + "Polls GitHub API every ", pollInterval, " seconds for real-time updates", ), vdom.H("li", map[string]any{ "className": "flex items-start gap-2", diff --git a/tsunami/engine/atomimpl.go b/tsunami/engine/atomimpl.go index c8cdcbd729..5458894eaf 100644 --- a/tsunami/engine/atomimpl.go +++ b/tsunami/engine/atomimpl.go @@ -6,6 +6,7 @@ package engine import ( "encoding/json" "fmt" + "reflect" "sync" ) @@ -102,3 +103,8 @@ func (a *AtomImpl[T]) GetMeta() *AtomMeta { defer a.lock.Unlock() return a.meta } + +func (a *AtomImpl[T]) GetAtomType() reflect.Type { + var zero T + return reflect.TypeOf(zero) +} diff --git a/tsunami/engine/rootelem.go b/tsunami/engine/rootelem.go index a9d42db773..bdd54bc10a 100644 --- a/tsunami/engine/rootelem.go +++ b/tsunami/engine/rootelem.go @@ -30,6 +30,7 @@ type genAtom interface { SetUsedBy(string, bool) GetUsedBy() []string GetMeta() *AtomMeta + GetAtomType() reflect.Type } type RootElem struct { diff --git a/tsunami/engine/schema.go b/tsunami/engine/schema.go index f855b235f8..87b637bc6a 100644 --- a/tsunami/engine/schema.go +++ b/tsunami/engine/schema.go @@ -78,6 +78,13 @@ func createStructDefinition(t reflect.Type) map[string]any { } } + // Add pattern constraint for string types + if fieldSchema["type"] == "string" { + if pattern := field.Tag.Get("pattern"); pattern != "" { + fieldSchema["pattern"] = pattern + } + } + properties[fieldInfo.FieldName] = fieldSchema // Add to required if not a pointer and not marked as omitempty @@ -195,7 +202,8 @@ func generateShallowJSONSchema(t reflect.Type, meta *AtomMeta) map[string]any { // Special case for []byte - treat as string with base64 encoding if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 { schema["type"] = "string" - schema["format"] = "base64" + schema["contentEncoding"] = "base64" + schema["contentMediaType"] = "application/octet-stream" return schema } @@ -233,17 +241,6 @@ func generateShallowJSONSchema(t reflect.Type, meta *AtomMeta) map[string]any { return schema } - - -// getAtomValueType returns the reflect.Type of the atom's value -func getAtomValueType(atom genAtom) reflect.Type { - val := atom.GetVal() - if val == nil { - return nil - } - return reflect.TypeOf(val) -} - // getAtomMeta extracts AtomMeta from the atom func getAtomMeta(atom genAtom) *AtomMeta { return atom.GetMeta() @@ -254,22 +251,22 @@ func generateSchemaFromAtoms(atoms map[string]genAtom, title, description string // Collect all struct definitions defs := make(map[reflect.Type]any) for _, atom := range atoms { - atomType := getAtomValueType(atom) + atomType := atom.GetAtomType() if atomType != nil { collectStructDefs(atomType, defs) } } - + // Generate properties for each atom properties := make(map[string]any) for atomName, atom := range atoms { - atomType := getAtomValueType(atom) + atomType := atom.GetAtomType() if atomType != nil { atomMeta := getAtomMeta(atom) properties[atomName] = generateShallowJSONSchema(atomType, atomMeta) } } - + // Build the final schema schema := map[string]any{ "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -278,7 +275,7 @@ func generateSchemaFromAtoms(atoms map[string]genAtom, title, description string "description": description, "properties": properties, } - + // Add definitions if any if len(defs) > 0 { definitions := make(map[string]any) @@ -287,7 +284,7 @@ func generateSchemaFromAtoms(atoms map[string]genAtom, title, description string } schema["definitions"] = definitions } - + return schema } diff --git a/tsunami/engine/serverhandlers.go b/tsunami/engine/serverhandlers.go index 0442c51f82..80dcb0292f 100644 --- a/tsunami/engine/serverhandlers.go +++ b/tsunami/engine/serverhandlers.go @@ -43,6 +43,12 @@ func newHTTPHandlers(client *ClientImpl) *httpHandlers { } } +func setNoCacheHeaders(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") +} + func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) { mux.HandleFunc("/api/render", h.handleRender) mux.HandleFunc("/api/updates", h.handleSSE) @@ -71,6 +77,8 @@ func (h *httpHandlers) handleRender(w http.ResponseWriter, r *http.Request) { } }() + setNoCacheHeaders(w) + if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -181,6 +189,8 @@ func (h *httpHandlers) handleData(w http.ResponseWriter, r *http.Request) { } }() + setNoCacheHeaders(w) + if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -203,6 +213,8 @@ func (h *httpHandlers) handleConfig(w http.ResponseWriter, r *http.Request) { } }() + setNoCacheHeaders(w) + switch r.Method { case http.MethodGet: h.handleConfigGet(w, r) @@ -270,6 +282,8 @@ func (h *httpHandlers) handleSchemas(w http.ResponseWriter, r *http.Request) { } }() + setNoCacheHeaders(w) + if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -327,12 +341,11 @@ func (h *httpHandlers) handleSSE(w http.ResponseWriter, r *http.Request) { } // Set SSE headers + setNoCacheHeaders(w) w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache, no-transform") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("X-Accel-Buffering", "no") // nginx hint // Use ResponseController for better flushing control rc := http.NewResponseController(w) @@ -441,6 +454,8 @@ func (h *httpHandlers) handleManifest(manifestFileBytes []byte) http.HandlerFunc } }() + setNoCacheHeaders(w) + if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return diff --git a/tsunami/prompts/system.md b/tsunami/prompts/system.md index 7ca32fd622..998d8593c0 100644 --- a/tsunami/prompts/system.md +++ b/tsunami/prompts/system.md @@ -456,7 +456,7 @@ For state shared across components or accessible to external systems, declare gl #### AtomMeta for External Integration -ConfigAtom and DataAtom require an AtomMeta parameter to provide schema information for external tools and AI agents. SharedAtom does not use AtomMeta since it's only for internal state sharing. +ConfigAtom and DataAtom require an AtomMeta parameter (can pass nil if not not needed) to provide schema information for external tools and AI agents. SharedAtom does not use AtomMeta since it's only for internal state sharing. ```go type AtomMeta struct { @@ -692,7 +692,7 @@ The style map in props mirrors React's style object pattern, making it familiar Quick styles can be added using a vdom.H("style", nil, "...") tag. You may also place CSS files in the `static` directory, and serve them directly with: ```go -vdom.H("link", map[string]any{"rel": "stylesheet", "src": "/static/mystyles.css"}) +vdom.H("link", map[string]any{"rel": "stylesheet", "href": "/static/mystyles.css"}) ``` ## Component Definition Pattern From 6aa0540d2a82b2968e12bcd5da35089ee4747ad1 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 11 Sep 2025 23:40:32 -0700 Subject: [PATCH 7/8] more cleanups --- tsunami/engine/atomimpl.go | 3 +-- tsunami/engine/schema.go | 4 ++-- tsunami/prompts/system.md | 24 ++++++++++++------------ 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/tsunami/engine/atomimpl.go b/tsunami/engine/atomimpl.go index 5458894eaf..092d1dd463 100644 --- a/tsunami/engine/atomimpl.go +++ b/tsunami/engine/atomimpl.go @@ -105,6 +105,5 @@ func (a *AtomImpl[T]) GetMeta() *AtomMeta { } func (a *AtomImpl[T]) GetAtomType() reflect.Type { - var zero T - return reflect.TypeOf(zero) + return reflect.TypeOf((*T)(nil)).Elem() } diff --git a/tsunami/engine/schema.go b/tsunami/engine/schema.go index 87b637bc6a..335926da7d 100644 --- a/tsunami/engine/schema.go +++ b/tsunami/engine/schema.go @@ -229,7 +229,7 @@ func generateShallowJSONSchema(t reflect.Type, meta *AtomMeta) map[string]any { } case reflect.Struct: // Reference the definition instead of recursing - schema["$ref"] = fmt.Sprintf("#/definitions/%s", t.Name()) + schema["$ref"] = fmt.Sprintf("#/$defs/%s", t.Name()) case reflect.Ptr: return generateShallowJSONSchema(t.Elem(), meta) case reflect.Interface: @@ -282,7 +282,7 @@ func generateSchemaFromAtoms(atoms map[string]genAtom, title, description string for t, def := range defs { definitions[t.Name()] = def } - schema["definitions"] = definitions + schema["$defs"] = definitions } return schema diff --git a/tsunami/prompts/system.md b/tsunami/prompts/system.md index 998d8593c0..002997b38a 100644 --- a/tsunami/prompts/system.md +++ b/tsunami/prompts/system.md @@ -454,9 +454,9 @@ var MyComponent = app.DefineComponent("MyComponent", func(_ struct{}) any { For state shared across components or accessible to external systems, declare global atoms as package variables: -#### AtomMeta for External Integration +#### app.AtomMeta for External Integration -ConfigAtom and DataAtom require an AtomMeta parameter (can pass nil if not not needed) to provide schema information for external tools and AI agents. SharedAtom does not use AtomMeta since it's only for internal state sharing. +app.ConfigAtom and app.DataAtom require an app.AtomMeta parameter (can pass nil if not not needed) to provide schema information for external tools and AI agents. app.SharedAtom does not use app.AtomMeta since it's only for internal state sharing. ```go type AtomMeta struct { @@ -479,25 +479,25 @@ var ( userPrefs = app.SharedAtom("userPrefs", UserPreferences{}) // ConfigAtom - Configuration that external systems can read/write - theme = app.ConfigAtom("theme", "dark", &AtomMeta{ + theme = app.ConfigAtom("theme", "dark", &app.AtomMeta{ Desc: "UI theme preference", Enum: []string{"light", "dark"}, }) - apiKey = app.ConfigAtom("apiKey", "", &AtomMeta{ + apiKey = app.ConfigAtom("apiKey", "", &app.AtomMeta{ Desc: "Authentication key for external services", Pattern: "^[A-Za-z0-9]{32}$", }) - maxRetries = app.ConfigAtom("maxRetries", 3, &AtomMeta{ + maxRetries = app.ConfigAtom("maxRetries", 3, &app.AtomMeta{ Desc: "Maximum retry attempts for failed requests", Min: app.Ptr(0.0), Max: app.Ptr(10.0), }) // DataAtom - Application data that external systems can read - currentUser = app.DataAtom("currentUser", UserStats{}, &AtomMeta{ + currentUser = app.DataAtom("currentUser", UserStats{}, &app.AtomMeta{ Desc: "Current user statistics and profile data", }) - lastPollResult = app.DataAtom("lastPoll", APIResult{}, &AtomMeta{ + lastPollResult = app.DataAtom("lastPoll", APIResult{}, &app.AtomMeta{ Desc: "Result from the most recent API polling operation", }) ) @@ -505,7 +505,7 @@ var ( - `app.Ptr(value)` - Helper to create pointers for Min/Max fields. Remember to use float64 literals like `app.Ptr(10.0)` since Min/Max expect \*float64. -AtomMeta provides top-level constraints for the atom value. For complex struct types, use struct tags on individual fields (covered in Schema Generation section). +app.AtomMeta provides top-level constraints for the atom value. For complex struct types, use struct tags on individual fields (covered in Schema Generation section). #### Using Global Atoms @@ -538,13 +538,13 @@ ConfigAtom and DataAtom automatically create REST endpoints: - `GET /api/config` - Returns all config atom values - `POST /api/config` - Updates (merges) config atom values - `GET /api/data` - Returns all data atom values -- `GET /api/schemas` - Returns JSON schema information for the /api/config and /api/data endpoints based on AtomMeta and type reflection information +- `GET /api/schemas` - Returns JSON schema information for the /api/config and /api/data endpoints based on app.AtomMeta and type reflection information This makes Tsunami applications naturally suitable for integration with external tools, monitoring systems, and AI agents that need to inspect or configure the application. #### Schema Generation for External Tools -When using ConfigAtom and DataAtom, you can provide schema metadata to help external AI tools understand your atom structure. Use the optional AtomMeta parameter and struct tags for detailed field schemas: +When using ConfigAtom and DataAtom, you can provide schema metadata to help external AI tools understand your atom structure. Use the optional app.AtomMeta parameter and struct tags for detailed field schemas: ```go type UserPrefs struct { @@ -553,7 +553,7 @@ type UserPrefs struct { APIEndpoint string `json:"apiEndpoint" desc:"API base URL" pattern:"^https?://.*"` } -userPrefs := app.ConfigAtom("userPrefs", UserPrefs{}, &AtomMeta{ +userPrefs := app.ConfigAtom("userPrefs", UserPrefs{}, &app.AtomMeta{ Desc: "User interface and behavior preferences", }) ``` @@ -567,7 +567,7 @@ userPrefs := app.ConfigAtom("userPrefs", UserPrefs{}, &AtomMeta{ - `enum:"val1,val2,val3"` - Comma-separated list of allowed string values - `pattern:"regex"` - Regular expression for string validation -For complex validation rules or special cases, document them in the AtomMeta description (e.g., "Note: 'retryDelays' must contain exactly 3 values in ascending order"). +For complex validation rules or special cases, document them in the app.AtomMeta description (e.g., "Note: 'retryDelays' must contain exactly 3 values in ascending order"). ## Component Code Conventions From 36c613e768f77495534486c125d97eb2c17051b4 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 12 Sep 2025 00:08:43 -0700 Subject: [PATCH 8/8] fix typo, fix enums (only strings), and set additionalproperties false for config/data schemas --- tsunami/engine/schema.go | 19 ++++++++++--------- tsunami/prompts/system.md | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tsunami/engine/schema.go b/tsunami/engine/schema.go index 335926da7d..fe4cf50afa 100644 --- a/tsunami/engine/schema.go +++ b/tsunami/engine/schema.go @@ -45,8 +45,8 @@ func createStructDefinition(t reflect.Type) map[string]any { fieldSchema["description"] = desc } - // Add enum values from "enum" tag if present - if enumTag := field.Tag.Get("enum"); enumTag != "" { + // Add enum values from "enum" tag if present (only for string types) + if enumTag := field.Tag.Get("enum"); enumTag != "" && fieldSchema["type"] == "string" { enumValues := make([]any, 0) for _, val := range strings.Split(enumTag, ",") { trimmed := strings.TrimSpace(val) @@ -170,8 +170,8 @@ func annotateSchemaWithAtomMeta(schema map[string]any, meta *AtomMeta) { } } - // Add enum values if specified - if len(meta.Enum) > 0 { + // Add enum values if specified (only for string types) + if len(meta.Enum) > 0 && schema["type"] == "string" { enumValues := make([]any, len(meta.Enum)) for i, v := range meta.Enum { enumValues[i] = v @@ -269,11 +269,12 @@ func generateSchemaFromAtoms(atoms map[string]genAtom, title, description string // Build the final schema schema := map[string]any{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "title": title, - "description": description, - "properties": properties, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "title": title, + "description": description, + "properties": properties, + "additionalProperties": false, } // Add definitions if any diff --git a/tsunami/prompts/system.md b/tsunami/prompts/system.md index 002997b38a..92b79f1c3d 100644 --- a/tsunami/prompts/system.md +++ b/tsunami/prompts/system.md @@ -456,7 +456,7 @@ For state shared across components or accessible to external systems, declare gl #### app.AtomMeta for External Integration -app.ConfigAtom and app.DataAtom require an app.AtomMeta parameter (can pass nil if not not needed) to provide schema information for external tools and AI agents. app.SharedAtom does not use app.AtomMeta since it's only for internal state sharing. +app.ConfigAtom and app.DataAtom require an app.AtomMeta parameter (can pass nil if not needed) to provide schema information for external tools and AI agents. app.SharedAtom does not use app.AtomMeta since it's only for internal state sharing. ```go type AtomMeta struct {