diff --git a/client-app/src/Bootstrap.ts b/client-app/src/Bootstrap.ts index c151ce040..aad2bb153 100755 --- a/client-app/src/Bootstrap.ts +++ b/client-app/src/Bootstrap.ts @@ -16,14 +16,20 @@ import {ContactService} from './examples/contact/svc/ContactService'; import {GitHubService} from './core/svc/GitHubService'; import {PortfolioService} from './core/svc/PortfolioService'; import {TaskService} from './examples/todo/TaskService'; +import {LlmChatService} from './examples/weatherv2/svc/LlmChatService'; +import {LlmToolService} from './examples/weatherv2/svc/LlmToolService'; +import {WeatherDataService} from './examples/weatherv2/svc/WeatherDataService'; declare module '@xh/hoist/core' { // Merge interface with XHApi class to include injected services. export interface XHApi { contactService: ContactService; gitHubService: GitHubService; + llmChatService: LlmChatService; + llmToolService: LlmToolService; portfolioService: PortfolioService; taskService: TaskService; + weatherDataService: WeatherDataService; } export interface HoistUser { diff --git a/client-app/src/apps/weatherv2.ts b/client-app/src/apps/weatherv2.ts new file mode 100644 index 000000000..5ae704e4c --- /dev/null +++ b/client-app/src/apps/weatherv2.ts @@ -0,0 +1,20 @@ +import '../Bootstrap'; + +import {XH} from '@xh/hoist/core'; +import {AppContainer} from '@xh/hoist/desktop/appcontainer'; +import {AppComponent} from '../examples/weatherv2/AppComponent'; +import {AppModel} from '../examples/weatherv2/AppModel'; +import {AuthModel} from '../core/AuthModel'; + +XH.renderApp({ + clientAppCode: 'weatherv2', + clientAppName: 'XH Weather V2', + componentClass: AppComponent, + modelClass: AppModel, + containerClass: AppContainer, + authModelClass: AuthModel, + isMobileApp: false, + enableLogout: true, + showBrowserContextMenu: true, + checkAccess: () => true +}); diff --git a/client-app/src/desktop/tabs/examples/ExamplesTabModel.ts b/client-app/src/desktop/tabs/examples/ExamplesTabModel.ts index a468953ac..0e4e316ad 100644 --- a/client-app/src/desktop/tabs/examples/ExamplesTabModel.ts +++ b/client-app/src/desktop/tabs/examples/ExamplesTabModel.ts @@ -47,6 +47,20 @@ export class ExamplesTabModel extends HoistModel { ) ] }, + { + title: 'Weather V2', + icon: Icon.sun(), + path: 'weatherv2', + srcPath: 'weatherv2', + text: [ + p( + 'Weather Dashboard V2 — a declarative, wirable dashboard with inter-widget bindings and LLM-driven spec generation.' + ), + p( + "Demonstrates dashboard-as-DSL: Hoist's native persisted state as a machine-readable spec that an LLM can generate and validate." + ) + ] + }, { title: 'Contact', icon: Icon.users(), diff --git a/client-app/src/examples/weatherv2/AppComponent.ts b/client-app/src/examples/weatherv2/AppComponent.ts new file mode 100644 index 000000000..b91588df1 --- /dev/null +++ b/client-app/src/examples/weatherv2/AppComponent.ts @@ -0,0 +1,113 @@ +import {hoistCmp, uses} from '@xh/hoist/core'; +import {frame, hframe} from '@xh/hoist/cmp/layout'; +import {appBar, appBarSeparator} from '@xh/hoist/desktop/cmp/appbar'; +import {button, dashCanvasAddViewButton} from '@xh/hoist/desktop/cmp/button'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {dashCanvas, dashCanvasWidgetChooser} from '@xh/hoist/desktop/cmp/dash'; +import {viewManager} from '@xh/hoist/desktop/cmp/viewmanager'; +import {Icon} from '@xh/hoist/icon'; +import {sparklesIcon} from './Icons'; +import {AppModel} from './AppModel'; +import {jsonHarnessPanel} from './harness/JsonHarnessPanel'; +import {chatHarnessPanel} from './harness/ChatHarnessPanel'; +import '../../core/Toolbox.scss'; +import './WeatherV2.scss'; + +export const AppComponent = hoistCmp({ + displayName: 'App', + model: uses(AppModel), + + render({model}) { + const { + weatherViewManager, + weatherV2DashModel, + manualEditingEnabled, + showJsonHarness, + showChatHarness, + showWidgetChooser + } = model, + {dashCanvasModel} = weatherV2DashModel, + activeWidgetChooser = showWidgetChooser && manualEditingEnabled, + showHarness = showChatHarness || showJsonHarness || activeWidgetChooser; + + return panel({ + tbar: appBar({ + icon: Icon.sun({size: '2x', prefix: 'fal'}), + title: 'Weather V2', + rightItems: [ + viewManager({model: weatherViewManager}), + appBarSeparator(), + button({ + testId: 'manual-editing-btn', + icon: Icon.edit(), + text: 'Manual Editing', + active: manualEditingEnabled, + outlined: true, + intent: manualEditingEnabled ? 'primary' : undefined, + onClick: () => (model.manualEditingEnabled = !manualEditingEnabled) + }), + dashCanvasAddViewButton({ + dashCanvasModel, + rightIcon: Icon.chevronDown(), + outlined: true, + disabled: !manualEditingEnabled + }), + appBarSeparator(), + button({ + testId: 'chat-btn', + icon: sparklesIcon(), + text: 'Dashboard Agent', + active: showChatHarness, + outlined: true, + intent: showChatHarness ? 'primary' : undefined, + onClick: () => (model.showChatHarness = !showChatHarness) + }), + button({ + testId: 'json-btn', + icon: Icon.json(), + text: 'JSON', + active: showJsonHarness, + outlined: true, + intent: showJsonHarness ? 'primary' : undefined, + onClick: () => (model.showJsonHarness = !showJsonHarness) + }), + button({ + testId: 'widget-chooser-btn', + icon: Icon.boxFull(), + text: 'Widgets', + active: showWidgetChooser, + outlined: true, + intent: activeWidgetChooser ? 'primary' : undefined, + disabled: !manualEditingEnabled, + onClick: () => (model.showWidgetChooser = !showWidgetChooser) + }), + appBarSeparator() + ], + appMenuButtonProps: {hideLogoutItem: false} + }), + item: hframe( + frame(dashCanvas({model: dashCanvasModel})), + panel({ + model: model.harnessPanelModel, + items: [ + showChatHarness ? chatHarnessPanel() : null, + showJsonHarness ? jsonHarnessPanel() : null, + activeWidgetChooser + ? panel({ + testId: 'widget-chooser-panel', + title: 'Widget Chooser', + icon: Icon.boxFull(), + compactHeader: true, + flex: 1, + minHeight: 0, + item: dashCanvasWidgetChooser({dashCanvasModel}) + }) + : null + ], + omit: !showHarness + }) + ), + className: 'weather-v2-app' + }); + } +}); diff --git a/client-app/src/examples/weatherv2/AppModel.ts b/client-app/src/examples/weatherv2/AppModel.ts new file mode 100644 index 000000000..ac896b006 --- /dev/null +++ b/client-app/src/examples/weatherv2/AppModel.ts @@ -0,0 +1,72 @@ +import {InitContext, managed, persist, XH} from '@xh/hoist/core'; +import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; +import {PanelModel} from '@xh/hoist/desktop/cmp/panel'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; +import { + autoRefreshAppOption, + themeAppOption, + sizingModeAppOption +} from '@xh/hoist/desktop/cmp/appOption'; +import {BaseAppModel} from '../../BaseAppModel'; +import {WeatherV2DashModel} from './dash/WeatherV2DashModel'; +import {LlmChatService} from './svc/LlmChatService'; +import {LlmToolService} from './svc/LlmToolService'; +import {WeatherDataService} from './svc/WeatherDataService'; + +export class AppModel extends BaseAppModel { + static instance: AppModel; + override persistWith = {localStorageKey: 'weatherV2App'}; + + @managed weatherV2DashModel: WeatherV2DashModel; + @managed weatherViewManager: ViewManagerModel; + @managed harnessPanelModel: PanelModel; + @persist @bindable manualEditingEnabled: boolean = true; + @persist @bindable showJsonHarness: boolean = false; + @persist @bindable showChatHarness: boolean = true; + @persist @bindable showWidgetChooser: boolean = false; + + constructor() { + super(); + makeObservable(this); + } + + override async initAsync(ctx: InitContext) { + await super.initAsync(ctx); + await XH.installServicesAsync([LlmChatService, LlmToolService, WeatherDataService], ctx); + + this.harnessPanelModel = new PanelModel({ + side: 'right', + defaultSize: 500, + minSize: 300, + resizable: true, + collapsible: false, + persistWith: {...this.persistWith, path: 'harnessPanel'} + }); + + await this.newSpan({name: 'toolbox.weatherv2.loadViews', parent: ctx.span}).run( + async () => { + this.weatherViewManager = await ViewManagerModel.createAsync({ + type: 'weatherDashboardV2', + typeDisplayName: 'Layout', + enableDefault: true, + enableAutoSave: false, + manageGlobal: XH.getUser().isHoistAdmin + }); + } + ); + + this.weatherV2DashModel = new WeatherV2DashModel(this.weatherViewManager); + + this.addReaction({ + track: () => this.manualEditingEnabled, + run: editing => { + this.weatherV2DashModel.dashCanvasModel.showGridBackground = editing; + }, + fireImmediately: true + }); + } + + override getAppOptions() { + return [themeAppOption(), sizingModeAppOption(), autoRefreshAppOption()]; + } +} diff --git a/client-app/src/examples/weatherv2/Icons.ts b/client-app/src/examples/weatherv2/Icons.ts new file mode 100644 index 000000000..8e9156e61 --- /dev/null +++ b/client-app/src/examples/weatherv2/Icons.ts @@ -0,0 +1,19 @@ +import {library} from '@fortawesome/fontawesome-svg-core'; +import { + faCloudRain, + faDropletPercent, + faCalendarDays, + faSparkles, + faTemperatureHalf, + faWind +} from '@fortawesome/pro-regular-svg-icons'; +import {Icon} from '@xh/hoist/icon'; + +library.add(faCloudRain, faDropletPercent, faCalendarDays, faSparkles, faTemperatureHalf, faWind); + +export const temperatureIcon = (opts = {}) => Icon.icon({iconName: 'temperature-half', ...opts}); +export const cloudRainIcon = (opts = {}) => Icon.icon({iconName: 'cloud-rain', ...opts}); +export const windIcon = (opts = {}) => Icon.icon({iconName: 'wind', ...opts}); +export const dropletPercentIcon = (opts = {}) => Icon.icon({iconName: 'droplet-percent', ...opts}); +export const calendarDaysIcon = (opts = {}) => Icon.icon({iconName: 'calendar-days', ...opts}); +export const sparklesIcon = (opts = {}) => Icon.icon({iconName: 'sparkles', ...opts}); diff --git a/client-app/src/examples/weatherv2/README.md b/client-app/src/examples/weatherv2/README.md new file mode 100644 index 000000000..281c84a00 --- /dev/null +++ b/client-app/src/examples/weatherv2/README.md @@ -0,0 +1,130 @@ +# Weather Dashboard V2 + +An LLM-driven dashboard example built on Hoist's `DashCanvasModel`. Demonstrates that Hoist's +native persisted state can function as a declarative DSL — one that an LLM can generate from +natural language, validate against a schema, and hydrate into a live, interactive dashboard. + +This app lives alongside the original Weather V1 example (`../weather/`) for A/B comparison. V1 is +unchanged; both share the same server-side weather endpoints. + +## Key Concepts + +- **Dashboard-as-DSL** — The "spec" format is not a new language. It is the JSON produced by + `DashCanvasModel.getPersistableState()`, extended with a `bindings` convention in each widget's + `viewState` for inter-widget wiring. +- **Inter-widget wiring** — Widgets declare typed inputs and outputs. A `WiringModel` coordinates + reactive data flow via MobX observables. A city chooser publishes `selectedCity`; display widgets + bind to it and update automatically. +- **LLM generation pipeline** — User describes a dashboard in natural language → system prompt with + widget schemas + spec format → LLM produces JSON spec → validation pipeline checks it → valid + specs hydrate into a live dashboard. +- **LLM tool use** — The LLM has access to callable tools (via Anthropic's function calling API) + for app operations: saving/loading views, toggling theme, opening panels. Tools execute + client-side; the server passes tool definitions through to the Anthropic API. + +## Directory Structure + +``` +weatherv2/ +├── AppModel.ts / AppComponent.ts — App shell (app bar + DashCanvas + harness panels) +├── Icons.ts, Types.ts — Shared icons and normalized weather data types +├── WeatherV2.scss — V2-specific styles +├── dash/ +│ ├── WeatherV2DashModel.ts — Central model: owns DashCanvasModel + WiringModel +│ ├── WiringModel.ts — Observable pub/sub for widget outputs +│ ├── WidgetRegistry.ts — Widget type registry + LLM prompt/schema generation +│ ├── types.ts — WidgetMeta, BindingSpec, DashSpec, ValidationResult +│ ├── validation.ts — 3-stage validation pipeline (structural/semantic/referential) +│ ├── unitUtils.ts — Temperature/wind unit conversion helpers +│ └── exampleSpecs.ts — Curated example dashboard specs +├── svc/ +│ ├── LlmChatService.ts — System prompt builder + LLM API client (HoistService) +│ ├── LlmToolService.ts — LLM tool definitions + execution (HoistService) +│ └── WeatherDataService.ts — Per-city weather data caching (HoistService) +├── harness/ +│ ├── JsonHarnessModel.ts/Panel.ts — JSON editor: view/edit/validate/apply specs +│ └── ChatHarnessModel.ts/Panel.ts — LLM chat: natural language → dashboard +├── widgets/ +│ ├── BaseWeatherWidgetModel.ts — Base class: resolveInput/publishOutput/persistence +│ ├── CityChooserWidget.ts — City select input, publishes selectedCity +│ ├── UnitsToggleWidget.ts — Imperial/metric toggle, publishes units +│ ├── CurrentConditionsWidget.ts — Solid gauge + conditions details +│ ├── ForecastChartWidget.ts — Multi-series configurable chart +│ ├── PrecipChartWidget.ts — Dual-axis precipitation chart +│ ├── WindChartWidget.ts — Wind speed + gusts chart +│ ├── SummaryGridWidget.ts — Daily overview grid +│ ├── MarkdownContentWidget.ts — Static markdown renderer +│ └── DashInspectorWidget.ts — Debug view of live wiring graph +└── planning/ — Design docs (see below) +``` + +### Server-side + +- `grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy` — POST `/llm/generate` endpoint +- `grails-app/services/io/xh/toolbox/llm/LlmService.groovy` — Anthropic Messages API proxy, + per-user rate limiting, config-driven (API key, model, max tokens) +- Weather endpoints are shared with V1 via `WeatherController`/`WeatherService` (OpenWeatherMap) + +## Widget Catalog (9 widgets) + +| Widget | Type | Key Inputs | Key Outputs | Purpose | +|--------|------|------------|-------------|---------| +| City Chooser | Input | — | `selectedCity` | Select a city from a dropdown | +| Units Toggle | Input | — | `units` | Switch imperial/metric | +| Current Conditions | Display | `city`, `units` | — | Temperature gauge + details | +| Forecast Chart | Display | `city`, `units` | — | Multi-series temp/humidity chart | +| Precip Chart | Display | `city` | — | Precipitation probability + volume | +| Wind Chart | Display | `city`, `units` | — | Wind speed + gusts | +| Summary Grid | Display | `city`, `units` | — | 5-day daily overview | +| Markdown Content | Utility | — | — | Static markdown display | +| Dash Inspector | Utility | — | — | Debug view of wiring state | + +## Planning Docs + +Detailed design documents are in [`./planning/`](./planning/): + +| Document | Contents | +|----------|----------| +| `PLAN.md` | Master 7-phase implementation plan with full task breakdown | +| `ROADMAP.md` | Phase-by-phase overview with scope and decision points | +| `PROGRESS.md` | Running log of what was built and when | +| `TASKS.md` | Structured checklist (~55 tasks across 7 phases) | +| `DSL-SPEC.md` | Dashboard spec schema, validation pipeline, hydration flow | +| `WIRING-DESIGN.md` | Inter-widget IO model: bindings, MobX propagation, examples | +| `WIDGET-CATALOG.md` | Full widget catalog with inputs/outputs/config | +| `WIDGET-SCHEMA.md` | WidgetMeta interface design and per-widget schema examples | +| `HOIST-CONVENTIONS.md` | House style checklist for V2 code | +| `DEPLOYMENT-MEMO.md` | LLM provider integration approach (Grails proxy) | +| `RISKS.md` | 10 identified risks with mitigations | +| `DEMO-SCRIPTS.md` | 5 customer demo scenarios with exact prompts | +| `PROMPT.md` | Original project brief and requirements | + +## Agent Notes + +Things that matter when working on this code: + +1. **Read Hoist docs first.** Use the `hoist-react` MCP tools (`hoist-search-docs`, + `hoist-search-symbols`) before writing code. The AGENTS.md at the repo root has the full primer. + Key docs: `desktop/cmp/dash`, `persistence`, `cmp/viewmanager`, `core`, `conventions`. + +2. **The spec IS the persisted state.** There is no translation layer. `DashCanvasModel.getPersistableState()` + returns the same JSON the JSON harness displays and the LLM generates. `setPersistableState()` + accepts it. Don't introduce an intermediate format. + +3. **Widget instance IDs are positional.** DashCanvasModel assigns IDs based on order in the `state` + array: first `cityChooser` → ID `"cityChooser"`, second → `"cityChooser_2"`. Bindings use these + IDs. The validation pipeline's `computeInstanceIds` must match DashCanvasModel's behavior. + +4. **WiringModel is MobX-reactive.** Widgets publish outputs to an observable map; downstream + widgets resolve inputs by reading from it. Changes propagate via standard MobX reactions. Don't + add event systems or manual subscriptions. + +5. **Don't modify V1.** The original weather app at `../weather/` stays unchanged for comparison. + Server-side weather endpoints (`WeatherController`/`WeatherService`) are shared — extend + additively, don't break V1's existing contract. + +6. **LLM proxy requires config.** The Grails `LlmService` reads `llmApiKey`, `llmModel`, + `llmMaxTokens`, and `llmRateLimit` from Hoist Config. Without `llmApiKey` configured on the + server, the chat harness will show a helpful error but the JSON harness still works. + +7. **Branch: `weatherv2`.** All V2 work happens on this branch. Commit frequently as checkpoints. diff --git a/client-app/src/examples/weatherv2/Types.ts b/client-app/src/examples/weatherv2/Types.ts new file mode 100644 index 000000000..0e76e315d --- /dev/null +++ b/client-app/src/examples/weatherv2/Types.ts @@ -0,0 +1,76 @@ +/** + * Normalized weather data types for V2. + * Widgets consume these types — not raw API responses. + */ + +/** Cached weather data for a single city. */ +export interface WeatherData { + city: string; + current: NormalizedCurrent; + forecast: NormalizedForecastEntry[]; + fetchedAt: number; +} + +/** Normalized current conditions. */ +export interface NormalizedCurrent { + temp: number; + feelsLike: number; + humidity: number; + pressure: number; + windSpeed: number; + windGust?: number; + conditions: string; + description: string; + iconCode: string; +} + +/** Normalized forecast entry (3-hour interval). */ +export interface NormalizedForecastEntry { + dt: number; + temp: number; + feelsLike: number; + tempMin: number; + tempMax: number; + humidity: number; + pressure: number; + windSpeed: number; + windGust?: number; + precipProbability: number; + precipVolume: number; + conditions: string; + description: string; + iconCode: string; +} + +/** Raw API response types — used only for parsing server responses. */ +export interface CurrentWeatherResponse { + weather: WeatherCondition[]; + main: {temp: number; feels_like: number; humidity: number; pressure: number}; + wind: {speed: number; gust?: number}; +} + +export interface ForecastResponse { + list: ForecastItem[]; +} + +export interface ForecastItem { + dt: number; + main: { + temp: number; + feels_like: number; + humidity: number; + pressure: number; + temp_max: number; + temp_min: number; + }; + weather: WeatherCondition[]; + wind: {speed: number; gust?: number}; + rain?: {'3h': number}; + pop: number; +} + +export interface WeatherCondition { + main: string; + description: string; + icon: string; +} diff --git a/client-app/src/examples/weatherv2/WeatherV2.scss b/client-app/src/examples/weatherv2/WeatherV2.scss new file mode 100644 index 000000000..bad1ed93a --- /dev/null +++ b/client-app/src/examples/weatherv2/WeatherV2.scss @@ -0,0 +1,732 @@ +.weather-v2-app { + height: 100%; + + // Ensure dashCanvas fills remaining space in hframe + > .xh-hframe { + flex: 1; + min-height: 0; + } + + // Round corners on all DashCanvas widget cards + .react-grid-item { + border-radius: var(--xh-border-radius-px); + overflow: hidden; + } +} + +.weather-v2-current-conditions { + height: 100%; + + &__gauge { + flex: 1; + min-height: 0; + width: 100%; + max-width: 400px; + } + + &__details { + padding: 4px 12px 8px; + align-items: center; + justify-content: center; + gap: 8px; + } + + &__icon { + width: 48px; + height: 48px; + } + + &__text { + font-size: 12px; + gap: 2px; + } + + &__description { + font-weight: 600; + font-size: 13px; + } +} + +// Validation display in JSON harness +.weather-v2-validation { + font-size: var(--xh-font-size-small-px); + + &--success { + color: var(--xh-intent-success); + } + + &--warning { + color: var(--xh-intent-warning); + } + + &--error { + color: var(--xh-intent-danger); + } +} + +.weather-v2-validation-details { + max-height: 150px; + overflow-y: auto; + padding: 4px 10px; + font-size: var(--xh-font-size-small-px); + font-family: var(--xh-font-family-mono); + border-top: var(--xh-border-solid); +} + +.weather-v2-validation-msg { + padding: 2px 0; + + &--error { + color: var(--xh-red); + } + + &--warning { + color: var(--xh-orange); + } +} + +// Markdown widget — styles for Hoist `markdown` component within dashboard cards. +// Adapted from Toolbox's MarkdownPanel.scss, scaled for compact widget context. +.weather-v2-markdown { + padding: 2px 10px 6px; + overflow: auto; + flex: 1; + h1 { + font-size: 1.25em; + border-bottom: 2px solid var(--xh-orange); + padding-bottom: 3px; + margin-top: 0; + margin-bottom: 6px; + } + h2 { + font-size: 1.15em; + border-bottom: 1px solid var(--xh-border-color); + padding-bottom: 3px; + margin-top: 12px; + margin-bottom: 6px; + } + h3 { + font-size: 1.05em; + margin-top: 10px; + margin-bottom: 5px; + } + h4 { + font-size: 1em; + margin-top: 8px; + margin-bottom: 4px; + } + p { + margin-top: 0; + margin-bottom: 6px; + } + + pre { + background-color: var(--xh-bg-alt); + border: 1px solid var(--xh-border-color); + border-radius: 6px; + padding: 10px 14px; + overflow-x: auto; + font-size: 12px; + line-height: 1.5; + margin: 10px 0; + } + code { + font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 0.88em; + } + :not(pre) > code { + background-color: var(--xh-bg-alt); + border: 1px solid var(--xh-border-color); + border-radius: 3px; + padding: 1px 5px; + } + + table { + border-collapse: collapse; + width: 100%; + margin: 10px 0; + font-size: 0.9em; + } + th, + td { + border: 1px solid var(--xh-border-color); + padding: 6px 10px; + text-align: left; + } + th { + background-color: var(--xh-bg-alt); + font-weight: 600; + } + tr:hover td { + background-color: var(--xh-bg-highlight); + } + + a { + color: var(--xh-blue); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 0.15s ease; + } + a:hover { + border-bottom-color: var(--xh-blue); + } + + ul, + ol { + padding-left: 20px; + margin: 8px 0; + } + li { + margin: 4px 0; + line-height: 1.5; + } + li > ul, + li > ol { + margin: 2px 0; + } + + blockquote { + border-left: 4px solid var(--xh-orange); + margin: 10px 0; + padding: 6px 12px; + background-color: var(--xh-bg-alt); + border-radius: 0 6px 6px 0; + } + blockquote p:first-child { + margin-top: 0; + } + blockquote p:last-child { + margin-bottom: 0; + } + + hr { + border: none; + border-top: 1px solid var(--xh-border-color); + margin: 20px 0; + } + + p { + line-height: 1.6; + margin: 10px 0; + } + p:first-child { + margin-top: 0; + } + + img { + max-width: 100%; + border-radius: 4px; + } + + strong { + font-weight: 600; + } +} + +// Chat harness — body wrapper: constrain height so messages scroll internally +// rather than growing the panel and pushing sibling panels down. +.weather-v2-chat-body { + min-height: 0; + overflow: hidden; +} + +// Chat harness — empty state +.weather-v2-chat-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + gap: 20px; + background: radial-gradient( + ellipse at center, + color-mix(in srgb, var(--xh-bg-alt) 40%, transparent) 0%, + transparent 70% + ); + + &__intro { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + color: var(--xh-text-color-muted); + } + + &__text { + font-size: var(--xh-font-size-px); + text-align: center; + max-width: 320px; + line-height: 1.5; + } + + &__suggestions { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + max-width: 360px; + } + + &__suggestions-label { + font-size: var(--xh-font-size-small-px); + color: var(--xh-text-color-muted); + font-weight: 600; + margin-bottom: 2px; + } +} + +.weather-v2-chat-suggestion { + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--xh-border-color); + background: var(--xh-bg-alt); + font-size: var(--xh-font-size-small-px); + line-height: 1.4; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease; + + &:hover { + background: var(--xh-bg-highlight); + border-color: var(--xh-blue-muted); + } + + &--meta { + font-style: italic; + } +} + +// Chat harness — message list +.weather-v2-chat-messages { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.weather-v2-chat-msg { + padding: 8px 10px; + border-radius: 6px; + + &--user { + background: var(--xh-bg-highlight); + align-self: flex-end; + max-width: 90%; + } + + &--assistant { + background: var(--xh-bg-alt); + max-width: 95%; + } + + &__role { + font-size: var(--xh-font-size-small-px); + font-weight: 600; + margin-bottom: 4px; + color: var(--xh-text-color-muted); + display: flex; + align-items: baseline; + gap: 6px; + } + + &__elapsed { + font-weight: 400; + font-size: 11px; + opacity: 0.7; + } + + &__content { + white-space: pre-wrap; + overflow-wrap: break-word; + font-size: var(--xh-font-size-px); + line-height: 1.4; + + &--typing::after { + content: '\25AE'; + animation: blink-cursor 0.6s step-end infinite; + color: var(--xh-text-color-muted); + margin-left: 1px; + } + } +} + +// Chat harness — markdown rendering within assistant message bubbles. +// Adapted from Toolbox's MarkdownPanel.scss, scaled for the narrow side panel. +.weather-v2-chat-markdown { + white-space: normal; + font-size: var(--xh-font-size-px); + line-height: 1.5; + + > :first-child { + margin-top: 0; + } + > :last-child { + margin-bottom: 0; + } + + p { + margin: 4px 0; + line-height: 1.5; + } + + h1, + h2, + h3, + h4 { + margin-top: 8px; + margin-bottom: 4px; + font-weight: 600; + } + h1 { + font-size: 1.15em; + } + h2 { + font-size: 1.1em; + } + h3, + h4 { + font-size: 1em; + } + + strong { + font-weight: 600; + } + + ul, + ol { + padding-left: 18px; + margin: 4px 0; + } + li { + margin: 2px 0; + } + + pre { + background: var(--xh-bg-alt); + border: 1px solid var(--xh-border-color); + border-radius: 4px; + padding: 6px 8px; + overflow-x: auto; + font-size: 11px; + line-height: 1.4; + margin: 6px 0; + } + code { + font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 0.9em; + } + :not(pre) > code { + background: var(--xh-bg-alt); + border: 1px solid var(--xh-border-color); + border-radius: 3px; + padding: 1px 4px; + } + + blockquote { + border-left: 3px solid var(--xh-orange); + margin: 6px 0; + padding: 4px 8px; + background: var(--xh-bg-alt); + border-radius: 0 4px 4px 0; + } + + table { + border-collapse: collapse; + width: 100%; + margin: 6px 0; + font-size: 0.9em; + } + th, + td { + border: 1px solid var(--xh-border-color); + padding: 4px 6px; + text-align: left; + } + th { + background: var(--xh-bg-alt); + font-weight: 600; + } + tr:hover td { + background: var(--xh-bg-highlight); + } + + a { + color: var(--xh-blue); + text-decoration: none; + } + + hr { + border: none; + border-top: 1px solid var(--xh-border-color); + margin: 8px 0; + } +} + +// Thinking dots animation +.weather-v2-thinking-dots { + display: flex; + gap: 4px; + padding: 4px 0; +} + +.weather-v2-thinking-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--xh-text-color-muted); + animation: thinking-bounce 1.2s ease-in-out infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } +} + +@keyframes thinking-bounce { + 0%, + 60%, + 100% { + transform: translateY(0); + opacity: 0.4; + } + 30% { + transform: translateY(-4px); + opacity: 1; + } +} + +@keyframes blink-cursor { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +// Chat harness — error display with retry/edit actions +.weather-v2-chat-error { + padding: 8px 10px; + border-top: var(--xh-border-solid); + background: var(--xh-red-trans1); + + &__message { + color: var(--xh-intent-danger); + font-size: var(--xh-font-size-small-px); + margin-bottom: 6px; + } + + &__actions { + gap: 6px; + } +} + +// Chat harness — tool call display +.weather-v2-tool-calls { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px 0; +} + +.weather-v2-tool-call { + border-radius: 4px; + font-size: var(--xh-font-size-small-px); + background: var(--xh-bg-highlight); + border: 1px solid var(--xh-border-color); + + &__summary { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + cursor: pointer; + font-size: var(--xh-font-size-small-px); + list-style: none; + user-select: none; + + &::-webkit-details-marker { + display: none; + } + + &::before { + content: '\25B6'; + font-size: 8px; + transition: transform 0.15s ease; + } + } + + &[open] > &__summary::before { + transform: rotate(90deg); + } + + &__detail { + padding: 4px 8px 6px 22px; + font-size: var(--xh-font-size-small-px); + border-top: 1px solid var(--xh-border-color); + } + + &__error { + color: var(--xh-intent-danger); + } +} + +// Chat harness — input +.weather-v2-chat-input { + display: flex; + gap: 8px; + padding: 8px; + border-top: var(--xh-border-solid); + align-items: flex-end; +} + +//------------------------------------------------------ +// Widget Settings Form — shown in modal alongside widget +//------------------------------------------------------ +.weather-v2-settings-form { + border-left: var(--xh-border-solid); + background: var(--xh-bg); + min-width: 280px; + max-width: 500px; + + &__header { + border-bottom: var(--xh-border-solid); + padding: 0 8px; + min-height: 32px; + gap: 6px; + font-weight: 600; + font-size: var(--xh-font-size-px); + } + + &__title { + font-weight: 600; + } + + &__body { + flex: 1; + overflow-y: auto; + padding: 12px; + } + + &__section { + &:not(:first-child) { + margin-top: 16px; + padding-top: 12px; + border-top: var(--xh-border-solid); + } + } + + &__section-title { + font-weight: 600; + font-size: var(--xh-font-size-small-px); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--xh-text-color-muted); + margin-bottom: 10px; + } + + &__field { + margin-bottom: 12px; + } + + &__field-label { + font-size: var(--xh-font-size-small-px); + font-weight: 500; + margin-bottom: 4px; + display: flex; + align-items: center; + } + + &__field-desc { + font-size: 11px; + color: var(--xh-text-color-muted); + margin-top: 3px; + line-height: 1.3; + } + + &__manual-input { + margin-top: 6px; + } + + &__linked-note { + margin-top: 4px; + font-size: var(--xh-font-size-small-px); + color: var(--xh-blue); + display: flex; + align-items: center; + gap: 4px; + } + + &__unbound-note { + margin-top: 4px; + font-size: var(--xh-font-size-small-px); + color: var(--xh-orange); + display: flex; + align-items: center; + gap: 4px; + } + + &__field-label--warning { + color: var(--xh-orange); + } + + &__warning-icon { + margin-right: 4px; + color: var(--xh-orange); + } + + // CodeInput needs explicit height on the formField — its internal elements + // use height: 100% which resolves only when a parent has explicit height. + .xh-form-field--code-input { + height: 230px; + } + + // Inline ColumnChooser — constrain the LeftRightChooser to fit within + // the settings panel without overflowing. + .xh-col-chooser { + height: 300px; + border: var(--xh-border-solid); + border-radius: var(--xh-border-radius-px); + } + + &__checkbox-group { + gap: 4px; + } + + &__checkbox-item { + gap: 6px; + padding: 2px 0; + } + + &__checkbox-label { + font-size: var(--xh-font-size-small-px); + } +} + +//------------------------------------------------------ +// Color-coded dot for input widget linkage indicators +//------------------------------------------------------ +.weather-v2-color-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 2px; + margin-right: 4px; + flex-shrink: 0; +} + +// Swatch row shown in consumer widget headers +.weather-v2-color-swatches { + display: flex; + gap: 3px; + align-items: center; + margin-left: 6px; +} diff --git a/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts new file mode 100644 index 000000000..298ecd9eb --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/WeatherV2DashModel.ts @@ -0,0 +1,371 @@ +import {HoistModel, managed, XH} from '@xh/hoist/core'; +import {ViewManagerModel} from '@xh/hoist/cmp/viewmanager'; +import {DashCanvasModel, DashViewModel} from '@xh/hoist/desktop/cmp/dash'; +import {Icon} from '@xh/hoist/icon'; +import {makeObservable} from '@xh/hoist/mobx'; +import {WiringModel} from './WiringModel'; +import {widgetRegistry} from './WidgetRegistry'; + +import {temperatureIcon, cloudRainIcon, calendarDaysIcon, windIcon} from '../Icons'; +import {cityChooserWidget} from '../widgets/CityChooserWidget'; +import {currentConditionsWidget} from '../widgets/CurrentConditionsWidget'; +import {forecastChartWidget} from '../widgets/ForecastChartWidget'; +import {precipChartWidget} from '../widgets/PrecipChartWidget'; +import {summaryGridWidget} from '../widgets/SummaryGridWidget'; +import {unitsToggleWidget} from '../widgets/UnitsToggleWidget'; +import {windChartWidget} from '../widgets/WindChartWidget'; +import {markdownContentWidget} from '../widgets/MarkdownContentWidget'; +import {dashInspectorWidget} from '../widgets/DashInspectorWidget'; + +/** + * Central model for the Weather V2 dashboard. + * + * Owns the DashCanvasModel (layout + widgets) and the WiringModel (inter-widget + * communication). Weather data is provided by WeatherDataService. + */ +export class WeatherV2DashModel extends HoistModel { + @managed wiringModel: WiringModel; + @managed dashCanvasModel: DashCanvasModel; + + viewManagerModel: ViewManagerModel; + + constructor(viewManagerModel: ViewManagerModel) { + super(); + makeObservable(this); + + this.viewManagerModel = viewManagerModel; + this.wiringModel = new WiringModel(); + + this.dashCanvasModel = new DashCanvasModel({ + persistWith: {viewManagerModel}, + rowHeight: 30, + allowsDrop: true, + viewSpecs: [ + { + id: 'cityChooser', + title: 'City Chooser', + icon: Icon.globe(), + groupName: 'Input', + content: cityChooserWidget, + unique: false, + allowRename: false, + width: 3, + height: 3 + }, + { + id: 'unitsToggle', + title: 'Units Toggle', + icon: Icon.gear(), + groupName: 'Input', + content: unitsToggleWidget, + unique: false, + allowRename: false, + width: 3, + height: 3 + }, + { + id: 'currentConditions', + title: 'Current Conditions', + icon: Icon.sun(), + groupName: 'Display', + content: currentConditionsWidget, + unique: false, + allowRename: false, + width: 4, + height: 8 + }, + { + id: 'forecastChart', + title: 'Forecast Chart', + icon: temperatureIcon(), + groupName: 'Display', + content: forecastChartWidget, + unique: false, + allowRename: false, + width: 8, + height: 8 + }, + { + id: 'precipChart', + title: 'Precipitation', + icon: cloudRainIcon(), + groupName: 'Display', + content: precipChartWidget, + unique: false, + allowRename: false, + width: 6, + height: 8 + }, + { + id: 'summaryGrid', + title: '5-Day Summary', + icon: calendarDaysIcon(), + groupName: 'Display', + content: summaryGridWidget, + unique: false, + allowRename: false, + width: 6, + height: 8 + }, + { + id: 'windChart', + title: 'Wind', + icon: windIcon(), + groupName: 'Display', + content: windChartWidget, + unique: false, + allowRename: false, + width: 6, + height: 8 + }, + { + id: 'markdownContent', + title: 'Markdown Content', + icon: Icon.info(), + groupName: 'Utility', + content: markdownContentWidget, + unique: false, + allowRename: false, + width: 4, + height: 5 + }, + { + id: 'dashInspector', + title: 'Dash Inspector', + icon: Icon.code(), + groupName: 'Utility', + content: dashInspectorWidget, + unique: true, + allowRename: false, + width: 6, + height: 8 + } + ], + initialState: [ + { + viewSpecId: 'cityChooser', + layout: {x: 0, y: 0, w: 3, h: 3}, + state: {selectedCity: 'New York'} + }, + { + viewSpecId: 'unitsToggle', + layout: {x: 0, y: 3, w: 3, h: 3}, + state: {units: 'imperial'} + }, + { + viewSpecId: 'currentConditions', + layout: {x: 3, y: 0, w: 4, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + } + } + }, + { + viewSpecId: 'forecastChart', + layout: {x: 7, y: 0, w: 5, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + }, + series: ['temp', 'feelsLike'], + chartType: 'line' + } + }, + { + viewSpecId: 'precipChart', + layout: {x: 0, y: 8, w: 6, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'} + } + } + }, + { + viewSpecId: 'windChart', + layout: {x: 6, y: 8, w: 6, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + } + } + }, + { + viewSpecId: 'summaryGrid', + layout: {x: 0, y: 16, w: 12, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + } + } + } + ] + }); + + // Widget lifecycle: track viewModel additions/removals to cull stale bindings + // and initialize input defaults. Registered BEFORE auto-title so culled viewState + // is settled before title computation runs. + let previousIds = new Set(); + this.addReaction({ + track: () => new Set(this.dashCanvasModel.viewModels.map(vm => vm.id)), + run: currentIds => { + // On removal: cull wiring outputs and stale bindings + for (const id of previousIds) { + if (!currentIds.has(id)) { + this.wiringModel.removeWidget(id); + this.cullBindingsTo(id); + } + } + + // On addition: seed input defaults into viewState + for (const id of currentIds) { + if (!previousIds.has(id)) { + this.initInputDefaults(id); + } + } + + previousIds = currentIds; + }, + fireImmediately: true + }); + + // Auto-title: reactively set titles on display widgets from their bound city. + // Runs at this level (not in content models) because DashCanvas may lazily render + // widget content — DashViewModels always exist regardless of render state. + // Delay avoids setting titles synchronously during the render cycle that fires + // when a new spec is applied (widget onLinked → publishOutput → this reaction). + this.addReaction({ + track: () => this.dashCanvasModel.viewModels.map(vm => this.computeAutoTitle(vm)), + run: titles => { + this.dashCanvasModel.viewModels.forEach((vm, i) => { + if (titles[i] != null) vm.title = titles[i]; + }); + }, + fireImmediately: true, + delay: 1 + }); + + // Lock/unlock canvas editing based on manual editing toggle. + this.addReaction({ + track: () => (XH.appModel as any).manualEditingEnabled, + run: enabled => { + const locked = !enabled; + this.dashCanvasModel.layoutLocked = locked; + this.dashCanvasModel.contentLocked = locked; + }, + fireImmediately: true + }); + } + + //-------------------------------------------------- + // Widget Lifecycle + //-------------------------------------------------- + + /** Remove bindings that reference a removed widget from all remaining widgets. */ + private cullBindingsTo(removedId: string) { + for (const vm of this.dashCanvasModel.viewModels) { + const bindings = vm.viewState?.bindings; + if (!bindings) continue; + + let changed = false; + const updated = {...bindings}; + for (const [inputName, binding] of Object.entries(updated)) { + if ( + binding && + typeof binding === 'object' && + 'fromWidget' in binding && + (binding as any).fromWidget === removedId + ) { + delete updated[inputName]; + changed = true; + } + } + + if (changed) { + const vs = {...vm.viewState}; + vs.bindings = Object.keys(updated).length > 0 ? updated : undefined; + vm.setViewState(vs); + } + } + } + + /** Seed declared input defaults into viewState for a newly added widget. */ + private initInputDefaults(widgetId: string) { + const vm = this.dashCanvasModel.viewModels.find(v => v.id === widgetId); + if (!vm) return; + + const meta = widgetRegistry.get(vm.viewSpec.id); + if (!meta) return; + + let changed = false; + const vs = {...(vm.viewState ?? {})}; + + for (const input of meta.inputs) { + if (input.default === undefined) continue; + // Only seed if neither a binding nor a manual value already exists + const hasBinding = vs.bindings?.[input.name] != null; + const hasManualValue = vs[input.name] !== undefined; + if (!hasBinding && !hasManualValue) { + vs[input.name] = input.default; + changed = true; + } + } + + if (changed) vm.setViewState(vs); + } + + //-------------------------------------------------- + // Auto-Title + //-------------------------------------------------- + + /** Compute an auto-generated title for a widget, or null to leave as-is. */ + private computeAutoTitle(vm: DashViewModel): string | null { + const specId = vm.viewSpec.id; + + // Markdown widget: title comes from its persisted state + if (specId === 'markdownContent') { + return vm.viewState?.title ?? 'Markdown Content'; + } + + // Input widgets: indexed titles when multiple of the same type exist + const staticTitle = STATIC_WIDGET_TITLES[specId]; + if (staticTitle) { + const siblings = this.dashCanvasModel.viewModels.filter(v => v.viewSpec.id === specId); + if (siblings.length > 1) { + const idx = siblings.findIndex(v => v.id === vm.id) + 1; + return `${staticTitle} #${idx}`; + } + return staticTitle; + } + + // Display widgets: title = prefix + city (from binding or direct state) + const titlePrefix = DISPLAY_WIDGET_TITLES[specId]; + if (!titlePrefix) return null; + + const cityBinding = vm.viewState?.bindings?.city; + const city = cityBinding + ? this.wiringModel.resolveBinding(cityBinding) + : vm.viewState?.city; + + return city ? `${titlePrefix} — ${city}` : titlePrefix; + } +} + +/** Input/utility widgets that always get a fixed title. */ +const STATIC_WIDGET_TITLES: Record = { + cityChooser: 'City', + unitsToggle: 'Units' +}; + +/** Display widgets that get auto-generated titles with city context. */ +const DISPLAY_WIDGET_TITLES: Record = { + currentConditions: 'Current Conditions', + forecastChart: 'Forecast', + precipChart: 'Precipitation', + windChart: 'Wind', + summaryGrid: '5-Day Summary' +}; diff --git a/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts b/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts new file mode 100644 index 000000000..2119efa93 --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/WidgetRegistry.ts @@ -0,0 +1,103 @@ +import {WidgetMeta} from './types'; + +/** + * Singleton registry of all V2 widget type schemas. + * Widget models register their static `meta` on module load. + * Used for validation, LLM prompt generation, and inspector display. + */ +class WidgetRegistryImpl { + private _schemas = new Map(); + + /** Register a widget type schema. */ + register(meta: WidgetMeta) { + this._schemas.set(meta.id, meta); + } + + /** Get schema for a widget type. */ + get(id: string): WidgetMeta | undefined { + return this._schemas.get(id); + } + + /** Get all registered widget schemas. */ + getAll(): WidgetMeta[] { + return Array.from(this._schemas.values()); + } + + /** Get all registered widget type IDs. */ + getIds(): string[] { + return Array.from(this._schemas.keys()); + } + + /** Check if a widget type is registered. */ + has(id: string): boolean { + return this._schemas.has(id); + } + + /** + * Generate a structured text description of all widget types + * for inclusion in the LLM system prompt. + */ + generateLLMPrompt(): string { + const widgets = this.getAll(); + if (!widgets.length) return 'No widget types registered.'; + + return widgets + .map(meta => { + const lines = [ + `### ${meta.id} — ${meta.title} [${meta.category}]`, + meta.description + ]; + + if (meta.inputs.length) { + lines.push('Inputs:'); + for (const input of meta.inputs) { + const req = input.required ? 'required' : 'optional'; + const def = + input.default !== undefined + ? `, default: ${JSON.stringify(input.default)}` + : ''; + lines.push( + ` - ${input.name} (${input.type}, ${req}${def}) — ${input.description}` + ); + } + } + + if (meta.outputs.length) { + lines.push('Outputs:'); + for (const output of meta.outputs) { + lines.push(` - ${output.name} (${output.type}) — ${output.description}`); + } + } + + const configEntries = Object.entries(meta.config); + if (configEntries.length) { + lines.push('Config:'); + for (const [key, def] of configEntries) { + const typeStr = + def.type === 'enum' ? `enum: ${def.enum?.join('|')}` : def.type; + const defStr = + def.default !== undefined + ? `, default: ${JSON.stringify(def.default)}` + : ''; + const descStr = def.description ? ` — ${def.description}` : ''; + lines.push(` - ${key} (${typeStr}${defStr})${descStr}`); + } + } + + lines.push(`Default size: ${meta.defaultSize.w}×${meta.defaultSize.h}`); + if (meta.idealSize) { + const parts: string[] = []; + if (meta.idealSize.w) parts.push(`w=${meta.idealSize.w}`); + if (meta.idealSize.h) parts.push(`h=${meta.idealSize.h}`); + lines.push( + `Ideal size: ${parts.join(', ')} — prefer this size to avoid wasting space.` + ); + } + return lines.join('\n'); + }) + .join('\n\n'); + } +} + +/** Singleton widget registry instance. */ +export const widgetRegistry = new WidgetRegistryImpl(); diff --git a/client-app/src/examples/weatherv2/dash/WiringModel.ts b/client-app/src/examples/weatherv2/dash/WiringModel.ts new file mode 100644 index 000000000..c05a4e094 --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/WiringModel.ts @@ -0,0 +1,62 @@ +import {HoistModel} from '@xh/hoist/core'; +import {action, makeObservable, observable} from '@xh/hoist/mobx'; +import {BindingSpec} from './types'; + +/** + * Runtime wiring coordinator for the V2 dashboard. + * + * Manages an observable map of widget outputs. Widgets publish outputs here; + * other widgets resolve their input bindings by reading from this map. + * MobX reactivity ensures changes propagate automatically. + */ +export class WiringModel extends HoistModel { + /** Map: widgetInstanceId → {outputName → current value} */ + @observable.ref + private _outputs = new Map>(); + + constructor() { + super(); + makeObservable(this); + } + + /** Publish a named output value from a widget instance. */ + @action + publishOutput(widgetId: string, outputName: string, value: any) { + const widgetOutputs = new Map(this._outputs.get(widgetId) ?? new Map()); + widgetOutputs.set(outputName, value); + const next = new Map(this._outputs); + next.set(widgetId, widgetOutputs); + this._outputs = next; + } + + /** Resolve a binding spec to its current value. */ + resolveBinding(binding: BindingSpec): any { + if (!binding) return undefined; + if ('const' in binding) return binding.const; + if ('fromWidget' in binding) { + const {fromWidget, output} = binding; + return this._outputs.get(fromWidget)?.get(output); + } + return undefined; + } + + /** Get all current output values for a widget. */ + getOutputs(widgetId: string): Map | undefined { + return this._outputs.get(widgetId); + } + + /** Get all outputs for all widgets — used by the inspector. */ + get allOutputs(): Map> { + return this._outputs; + } + + /** Remove all outputs for a widget (called on widget removal). */ + @action + removeWidget(widgetId: string) { + if (this._outputs.has(widgetId)) { + const next = new Map(this._outputs); + next.delete(widgetId); + this._outputs = next; + } + } +} diff --git a/client-app/src/examples/weatherv2/dash/colorCoding.ts b/client-app/src/examples/weatherv2/dash/colorCoding.ts new file mode 100644 index 000000000..2f232584d --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/colorCoding.ts @@ -0,0 +1,91 @@ +import {widgetRegistry} from './WidgetRegistry'; +import {AppModel} from '../AppModel'; + +/** + * Color palette for input widgets. Colors are assigned in order of widget + * appearance in the dashboard. Each input widget instance gets a stable color + * used in its header, the provider widget picker, and consumer widget headers. + */ +const INPUT_WIDGET_COLORS = [ + '#4A90D9', // Blue + '#7B61FF', // Purple + '#E67E22', // Orange + '#27AE60', // Green + '#E74C3C', // Red + '#16A085', // Teal + '#F39C12', // Amber + '#8E44AD' // Violet +]; + +/** + * Get the assigned color for an input widget instance. + * Returns undefined for non-input widgets. + */ +export function getInputWidgetColor(widgetId: string): string | undefined { + const dashModel = AppModel.instance?.weatherV2DashModel; + if (!dashModel) return undefined; + + const canvasModel = dashModel.dashCanvasModel, + inputVMs = canvasModel.viewModels.filter(vm => { + const meta = widgetRegistry.get(vm.viewSpec.id); + return meta?.category === 'input'; + }); + + const idx = inputVMs.findIndex(vm => vm.id === widgetId); + if (idx < 0) return undefined; + return INPUT_WIDGET_COLORS[idx % INPUT_WIDGET_COLORS.length]; +} + +/** + * Get all input widget colors as a map of widgetId → color. + * Used by WeatherV2DashModel for reactive title decoration. + */ +export function getAllInputWidgetColors(): Map { + const dashModel = AppModel.instance?.weatherV2DashModel; + if (!dashModel) return new Map(); + + const canvasModel = dashModel.dashCanvasModel, + result = new Map(); + + let colorIdx = 0; + for (const vm of canvasModel.viewModels) { + const meta = widgetRegistry.get(vm.viewSpec.id); + if (meta?.category === 'input') { + result.set(vm.id, INPUT_WIDGET_COLORS[colorIdx % INPUT_WIDGET_COLORS.length]); + colorIdx++; + } + } + return result; +} + +/** + * Get colors of provider widgets bound to a consuming widget. + * Returns an array of colors for all active input bindings. + */ +export function getConsumerWidgetColors(widgetId: string): string[] { + const dashModel = AppModel.instance?.weatherV2DashModel; + if (!dashModel) return []; + + const canvasModel = dashModel.dashCanvasModel, + vm = canvasModel.viewModels.find(v => v.id === widgetId); + if (!vm) return []; + + const bindings = vm.viewState?.bindings; + if (!bindings) return []; + + const colors: string[] = []; + const seenProviders = new Set(); + for (const binding of Object.values(bindings)) { + if (binding && typeof binding === 'object' && 'fromWidget' in binding) { + const providerId = (binding as any).fromWidget; + if (!seenProviders.has(providerId)) { + seenProviders.add(providerId); + const color = getInputWidgetColor(providerId); + if (color) colors.push(color); + } + } + } + return colors; +} + +export {INPUT_WIDGET_COLORS}; diff --git a/client-app/src/examples/weatherv2/dash/exampleSpecs.ts b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts new file mode 100644 index 000000000..d3f7c4ff1 --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/exampleSpecs.ts @@ -0,0 +1,231 @@ +import {DashSpec} from './types'; + +export interface ExampleSpec { + name: string; + description: string; + spec: DashSpec; +} + +/** Minimal: Single city with conditions + forecast chart. */ +const minimalSpec: DashSpec = { + version: 1, + state: [ + { + viewSpecId: 'cityChooser', + layout: {x: 0, y: 0, w: 3, h: 3}, + state: {selectedCity: 'New York'} + }, + { + viewSpecId: 'currentConditions', + layout: {x: 3, y: 0, w: 4, h: 8}, + state: { + bindings: {city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}} + } + }, + { + viewSpecId: 'forecastChart', + layout: {x: 7, y: 0, w: 5, h: 8}, + state: { + bindings: {city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}}, + series: ['temp'], + chartType: 'line' + } + } + ] +}; + +/** Full Dashboard: All widget types wired together. */ +const fullSpec: DashSpec = { + version: 1, + state: [ + { + viewSpecId: 'cityChooser', + layout: {x: 0, y: 0, w: 3, h: 3}, + state: {selectedCity: 'New York'} + }, + { + viewSpecId: 'unitsToggle', + layout: {x: 0, y: 3, w: 3, h: 3}, + state: {units: 'imperial'} + }, + { + viewSpecId: 'currentConditions', + layout: {x: 3, y: 0, w: 4, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + } + } + }, + { + viewSpecId: 'forecastChart', + layout: {x: 7, y: 0, w: 5, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + }, + series: ['temp', 'feelsLike'], + chartType: 'line' + } + }, + { + viewSpecId: 'precipChart', + layout: {x: 0, y: 8, w: 6, h: 8}, + state: { + bindings: {city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}} + } + }, + { + viewSpecId: 'windChart', + layout: {x: 6, y: 8, w: 6, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + }, + showGusts: true + } + }, + { + viewSpecId: 'summaryGrid', + layout: {x: 0, y: 16, w: 12, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + } + } + } + ] +}; + +/** City Comparison: Two cities side by side. */ +const comparisonSpec: DashSpec = { + version: 1, + state: [ + { + viewSpecId: 'cityChooser', + layout: {x: 0, y: 0, w: 3, h: 3}, + state: {selectedCity: 'New York'} + }, + { + viewSpecId: 'cityChooser', + layout: {x: 6, y: 0, w: 3, h: 3}, + state: {selectedCity: 'London'} + }, + { + viewSpecId: 'unitsToggle', + layout: {x: 3, y: 0, w: 3, h: 3}, + state: {units: 'imperial'} + }, + { + viewSpecId: 'forecastChart', + layout: {x: 0, y: 3, w: 6, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + }, + series: ['temp'], + chartType: 'line' + } + }, + { + viewSpecId: 'forecastChart', + layout: {x: 6, y: 3, w: 6, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_1', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + }, + series: ['temp'], + chartType: 'line' + } + }, + { + viewSpecId: 'currentConditions', + layout: {x: 0, y: 11, w: 6, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + } + } + }, + { + viewSpecId: 'currentConditions', + layout: {x: 6, y: 11, w: 6, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_1', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + } + } + } + ] +}; + +/** Annotated: Markdown header + display widgets + inspector. */ +const annotatedSpec: DashSpec = { + version: 1, + state: [ + { + viewSpecId: 'markdownContent', + layout: {x: 0, y: 0, w: 12, h: 3}, + state: { + title: 'Dashboard Guide', + content: + '# Weather Dashboard V2\n\nThis dashboard demonstrates **inter-widget wiring**. The City Chooser and Units Toggle publish outputs that drive all display widgets. Open the **Dash Inspector** to see live binding values.' + } + }, + { + viewSpecId: 'cityChooser', + layout: {x: 0, y: 3, w: 3, h: 3}, + state: {selectedCity: 'Tokyo'} + }, + { + viewSpecId: 'unitsToggle', + layout: {x: 3, y: 3, w: 3, h: 3}, + state: {units: 'metric'} + }, + { + viewSpecId: 'forecastChart', + layout: {x: 6, y: 3, w: 6, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + }, + series: ['temp', 'humidity'], + chartType: 'area' + } + }, + { + viewSpecId: 'currentConditions', + layout: {x: 0, y: 6, w: 6, h: 8}, + state: { + bindings: { + city: {fromWidget: 'cityChooser_0', output: 'selectedCity'}, + units: {fromWidget: 'unitsToggle_0', output: 'units'} + } + } + }, + { + viewSpecId: 'dashInspector', + layout: {x: 0, y: 14, w: 12, h: 7} + } + ] +}; + +export const EXAMPLE_SPECS: ExampleSpec[] = [ + {name: 'Minimal', description: 'City chooser + conditions + forecast', spec: minimalSpec}, + {name: 'Full Dashboard', description: 'All widget types wired together', spec: fullSpec}, + {name: 'City Comparison', description: 'Two cities side by side', spec: comparisonSpec}, + { + name: 'Annotated', + description: 'Markdown guide + inspector + display widgets', + spec: annotatedSpec + } +]; diff --git a/client-app/src/examples/weatherv2/dash/types.ts b/client-app/src/examples/weatherv2/dash/types.ts new file mode 100644 index 000000000..1ee9e6d7d --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/types.ts @@ -0,0 +1,95 @@ +/** + * Type definitions for the V2 dashboard wiring, widget schema, and spec system. + */ + +//-------------------------------------------------- +// Widget Schema Types +//-------------------------------------------------- + +/** Full metadata for a widget type — schema, inputs, outputs, config. */ +export interface WidgetMeta { + id: string; + title: string; + description: string; + category: 'input' | 'display' | 'utility'; + inputs: InputDef[]; + outputs: OutputDef[]; + config: Record; + defaultSize: {w: number; h: number}; + idealSize?: {w?: number; h?: number}; + minSize?: {w?: number; h?: number}; + maxSize?: {w?: number; h?: number}; +} + +/** Declared input a widget accepts. */ +export interface InputDef { + name: string; + type: string; + required?: boolean; + default?: any; + enum?: string[]; + description: string; +} + +/** Declared output a widget publishes. */ +export interface OutputDef { + name: string; + type: string; + description: string; +} + +/** Config property definition within a widget schema. */ +export interface ConfigPropertyDef { + type: 'string' | 'number' | 'boolean' | 'enum' | 'string[]' | 'markdown'; + description?: string; + default?: any; + enum?: string[]; + min?: number; + max?: number; + required?: boolean; +} + +//-------------------------------------------------- +// Wiring / Binding Types +//-------------------------------------------------- + +/** A binding connects a widget input to a source. */ +export type BindingSpec = {fromWidget: string; output: string} | {const: any}; + +/** Map of input name → binding spec for a widget instance. */ +export type BindingsMap = Record; + +//-------------------------------------------------- +// Dashboard Spec Types +//-------------------------------------------------- + +/** The full dashboard spec — matches DashCanvasModel's persisted state. */ +export interface DashSpec { + version?: number; + state: DashWidgetState[]; +} + +/** State for a single widget instance in the spec. */ +export interface DashWidgetState { + viewSpecId: string; + layout: {x: number; y: number; w: number; h: number}; + title?: string; + state?: Record; +} + +//-------------------------------------------------- +// Validation Types +//-------------------------------------------------- + +export interface ValidationResult { + valid: boolean; + errors: ValidationMessage[]; + warnings: ValidationMessage[]; +} + +export interface ValidationMessage { + level: 'error' | 'warning'; + path: string; + code: string; + message: string; +} diff --git a/client-app/src/examples/weatherv2/dash/unitUtils.ts b/client-app/src/examples/weatherv2/dash/unitUtils.ts new file mode 100644 index 000000000..55c1d8fde --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/unitUtils.ts @@ -0,0 +1,44 @@ +/** + * Unit conversion utilities. Server returns imperial (°F, mph). + * Display widgets convert to metric when their `units` input is 'metric'. + */ + +/** Convert Fahrenheit to Celsius. */ +export function toMetricTemp(f: number): number { + return Math.round(((f - 32) * 5) / 9); +} + +/** Convert mph to m/s. */ +export function toMetricWind(mph: number): number { + return Math.round(mph * 0.44704 * 10) / 10; +} + +/** Format temperature with unit suffix. */ +export function fmtTemp(f: number, units: string = 'imperial'): string { + return units === 'metric' ? `${toMetricTemp(f)}°C` : `${Math.round(f)}°F`; +} + +/** Format wind speed with unit suffix. */ +export function fmtWind(mph: number, units: string = 'imperial'): string { + return units === 'metric' ? `${toMetricWind(mph)} m/s` : `${Math.round(mph)} mph`; +} + +/** Get temperature unit label. */ +export function tempUnit(units: string = 'imperial'): string { + return units === 'metric' ? '°C' : '°F'; +} + +/** Get wind unit label. */ +export function windUnit(units: string = 'imperial'): string { + return units === 'metric' ? 'm/s' : 'mph'; +} + +/** Convert temperature value for charting (no rounding). */ +export function convertTemp(f: number, units: string = 'imperial'): number { + return units === 'metric' ? ((f - 32) * 5) / 9 : f; +} + +/** Convert wind value for charting (no rounding). */ +export function convertWind(mph: number, units: string = 'imperial'): number { + return units === 'metric' ? mph * 0.44704 : mph; +} diff --git a/client-app/src/examples/weatherv2/dash/validation.ts b/client-app/src/examples/weatherv2/dash/validation.ts new file mode 100644 index 000000000..174e3c500 --- /dev/null +++ b/client-app/src/examples/weatherv2/dash/validation.ts @@ -0,0 +1,364 @@ +import {widgetRegistry} from './WidgetRegistry'; +import {ValidationResult, ValidationMessage, DashSpec, DashWidgetState, BindingSpec} from './types'; + +const CURRENT_VERSION = 1; + +/** + * Migrations map: version → transform function. + * For V1 (initial release) there are no migrations. The framework is in place. + */ +const migrations: Record DashSpec> = {}; + +/** + * Migrate a spec to the current version. + */ +export function migrateSpec(spec: DashSpec): DashSpec { + let current = {...spec}; + while ((current.version ?? 1) < CURRENT_VERSION) { + const next = (current.version ?? 1) + 1; + const migrate = migrations[next]; + if (!migrate) break; + current = migrate(current); + } + return current; +} + +/** + * Full three-stage validation pipeline. + * Returns a ValidationResult with errors and warnings. + */ +export function validateSpec(spec: DashSpec): ValidationResult { + const errors: ValidationMessage[] = [], + warnings: ValidationMessage[] = []; + + if (!spec || typeof spec !== 'object') { + errors.push(msg('error', '', 'INVALID_SPEC', 'Spec must be a non-null object.')); + return {valid: false, errors, warnings}; + } + + if (!Array.isArray(spec.state)) { + errors.push(msg('error', 'state', 'MISSING_STATE', 'Spec must have a "state" array.')); + return {valid: false, errors, warnings}; + } + + // Stage 1: Structural validation + validateStructural(spec, errors, warnings); + + // Stage 2: Semantic validation + validateSemantic(spec, errors, warnings); + + // Stage 3: Referential validation (wiring graph) + validateReferential(spec, errors, warnings); + + return {valid: errors.length === 0, errors, warnings}; +} + +//-------------------------------------------------- +// Stage 1: Structural +//-------------------------------------------------- +function validateStructural( + spec: DashSpec, + errors: ValidationMessage[], + warnings: ValidationMessage[] +) { + const registeredIds = new Set(widgetRegistry.getIds()); + + spec.state.forEach((widget, idx) => { + const path = `state[${idx}]`; + + if (!widget.viewSpecId) { + errors.push( + msg('error', path, 'MISSING_VIEW_SPEC_ID', 'Widget must have a viewSpecId.') + ); + } else if (!registeredIds.has(widget.viewSpecId)) { + errors.push( + msg( + 'error', + `${path}.viewSpecId`, + 'UNKNOWN_WIDGET_TYPE', + `Unknown widget type "${widget.viewSpecId}". Available: ${[...registeredIds].join(', ')}.` + ) + ); + } + + if (!widget.layout) { + errors.push(msg('error', path, 'MISSING_LAYOUT', 'Widget must have a layout.')); + } else { + validateLayout(widget.layout, `${path}.layout`, errors, warnings); + } + }); +} + +function validateLayout( + layout: DashWidgetState['layout'], + path: string, + errors: ValidationMessage[], + warnings: ValidationMessage[] +) { + const {x, y, w, h} = layout; + + if (typeof x !== 'number' || x < 0) { + errors.push(msg('error', `${path}.x`, 'INVALID_X', `x must be >= 0, got ${x}.`)); + } + if (typeof y !== 'number' || y < 0) { + errors.push(msg('error', `${path}.y`, 'INVALID_Y', `y must be >= 0, got ${y}.`)); + } + if (typeof w !== 'number' || w < 1 || w > 12) { + errors.push(msg('error', `${path}.w`, 'INVALID_W', `w must be 1-12, got ${w}.`)); + } + if (typeof h !== 'number' || h < 1) { + errors.push(msg('error', `${path}.h`, 'INVALID_H', `h must be >= 1, got ${h}.`)); + } + if (typeof x === 'number' && typeof w === 'number' && x + w > 12) { + errors.push( + msg('error', path, 'LAYOUT_OVERFLOW', `Widget extends past column 12 (x=${x}, w=${w}).`) + ); + } +} + +//-------------------------------------------------- +// Stage 2: Semantic +//-------------------------------------------------- +function validateSemantic( + spec: DashSpec, + errors: ValidationMessage[], + warnings: ValidationMessage[] +) { + spec.state.forEach((widget, idx) => { + const path = `state[${idx}]`; + const meta = widgetRegistry.get(widget.viewSpecId); + if (!meta) return; // already flagged in structural + + const widgetState = widget.state ?? {}; + const configKeys = Object.keys(meta.config); + + // Check config property types and enum values + for (const [key, def] of Object.entries(meta.config)) { + const value = widgetState[key]; + if (value === undefined) continue; + + if (def.type === 'enum' && def.enum && !def.enum.includes(value)) { + errors.push( + msg( + 'error', + `${path}.state.${key}`, + 'INVALID_ENUM', + `"${value}" is not a valid value for ${key}. Must be one of: ${def.enum.join(', ')}.` + ) + ); + } + + if (def.type === 'boolean' && typeof value !== 'boolean') { + errors.push( + msg( + 'error', + `${path}.state.${key}`, + 'TYPE_MISMATCH', + `${key} must be a boolean, got ${typeof value}.` + ) + ); + } + + if (def.type === 'number' && typeof value !== 'number') { + errors.push( + msg( + 'error', + `${path}.state.${key}`, + 'TYPE_MISMATCH', + `${key} must be a number, got ${typeof value}.` + ) + ); + } + } + + // Warn about unknown state keys (excluding 'bindings', known config, input names, and output names) + const inputNames = meta.inputs.map(i => i.name); + const outputNames = meta.outputs.map(o => o.name); + const knownKeys = new Set([...configKeys, ...inputNames, ...outputNames, 'bindings']); + for (const key of Object.keys(widgetState)) { + if (!knownKeys.has(key)) { + warnings.push( + msg( + 'warning', + `${path}.state.${key}`, + 'UNKNOWN_STATE_KEY', + `Unknown state key "${key}" on ${widget.viewSpecId}. It will be preserved but may have no effect.` + ) + ); + } + } + + // Check binding inputs are declared + const bindings = widgetState.bindings; + if (bindings) { + const declaredInputNames = new Set(meta.inputs.map(i => i.name)); + for (const inputName of Object.keys(bindings)) { + if (!declaredInputNames.has(inputName)) { + warnings.push( + msg( + 'warning', + `${path}.state.bindings.${inputName}`, + 'UNKNOWN_INPUT', + `"${inputName}" is not a declared input of ${widget.viewSpecId}.` + ) + ); + } + } + } + + // Warn about required inputs without bindings or manual values + for (const input of meta.inputs) { + if ( + input.required && + !bindings?.[input.name] && + widgetState[input.name] === undefined + ) { + warnings.push( + msg( + 'warning', + `${path}.state`, + 'UNBOUND_REQUIRED_INPUT', + `Required input "${input.name}" on ${widget.viewSpecId} has no binding or manual value. Widget is unconfigured.` + ) + ); + } + } + }); +} + +//-------------------------------------------------- +// Stage 3: Referential (wiring graph) +//-------------------------------------------------- +function validateReferential( + spec: DashSpec, + errors: ValidationMessage[], + _warnings: ValidationMessage[] +) { + // Compute expected widget instance IDs (matching DashCanvasModel's assignment) + const instanceIds = computeInstanceIds(spec.state); + const idSet = new Set(instanceIds); + const typeById = new Map(); // instanceId → viewSpecId + instanceIds.forEach((id, idx) => typeById.set(id, spec.state[idx].viewSpecId)); + + spec.state.forEach((widget, idx) => { + const path = `state[${idx}]`; + const bindings = widget.state?.bindings; + if (!bindings) return; + + for (const [inputName, rawBinding] of Object.entries(bindings)) { + const binding = rawBinding as BindingSpec; + const bindPath = `${path}.state.bindings.${inputName}`; + + if (binding && 'fromWidget' in binding) { + const fromWidget = binding.fromWidget; + const output = binding.output; + + // Check fromWidget exists + if (!idSet.has(fromWidget)) { + errors.push( + msg( + 'error', + bindPath, + 'DANGLING_WIDGET_REF', + `Binding references widget "${fromWidget}" which does not exist. Available: ${[...idSet].join(', ')}.` + ) + ); + continue; + } + + // Check output exists on source widget type + const sourceType = typeById.get(fromWidget); + if (sourceType) { + const sourceMeta = widgetRegistry.get(sourceType); + if (sourceMeta) { + const hasOutput = sourceMeta.outputs.some(o => o.name === output); + if (!hasOutput) { + errors.push( + msg( + 'error', + bindPath, + 'DANGLING_OUTPUT_REF', + `Widget type "${sourceType}" has no output named "${output}".` + ) + ); + } + } + } + } + } + }); + + // Cycle detection via topological sort + const graph = new Map>(); + instanceIds.forEach(id => graph.set(id, new Set())); + + spec.state.forEach((widget, idx) => { + const bindings = widget.state?.bindings; + if (!bindings) return; + const thisId = instanceIds[idx]; + + for (const rawBinding of Object.values(bindings) as BindingSpec[]) { + if (rawBinding && 'fromWidget' in rawBinding && idSet.has(rawBinding.fromWidget)) { + // Edge: fromWidget → thisWidget (data flows from source to consumer) + graph.get(rawBinding.fromWidget)?.add(thisId); + } + } + }); + + if (hasCycle(graph)) { + errors.push( + msg( + 'error', + 'state', + 'BINDING_CYCLE', + 'Circular dependency detected in widget bindings.' + ) + ); + } +} + +/** + * Compute widget instance IDs matching DashCanvasModel's 0-indexed assignment: + * first instance of type X → "X_0", second → "X_1", etc. + */ +export function computeInstanceIds(state: DashWidgetState[]): string[] { + const counts = new Map(); + return state.map(widget => { + const idx = counts.get(widget.viewSpecId) ?? 0; + counts.set(widget.viewSpecId, idx + 1); + return `${widget.viewSpecId}_${idx}`; + }); +} + +/** Cycle detection via DFS on a directed graph. */ +function hasCycle(graph: Map>): boolean { + const visited = new Set(); + const stack = new Set(); + + function dfs(node: string): boolean { + if (stack.has(node)) return true; + if (visited.has(node)) return false; + visited.add(node); + stack.add(node); + for (const neighbor of graph.get(node) ?? []) { + if (dfs(neighbor)) return true; + } + stack.delete(node); + return false; + } + + for (const node of graph.keys()) { + if (dfs(node)) return true; + } + return false; +} + +/** Helper to create a validation message. */ +function msg( + level: 'error' | 'warning', + path: string, + code: string, + message: string +): ValidationMessage { + return {level, path, code, message}; +} diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts new file mode 100644 index 000000000..0c7c65d07 --- /dev/null +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessModel.ts @@ -0,0 +1,330 @@ +import {HoistModel, managed, TaskObserver, XH} from '@xh/hoist/core'; +import {action, bindable, computed, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; +import {DashSpec} from '../dash/types'; +import {ChatMessage, ContentBlock} from '../svc/LlmChatService'; +import {AppModel} from '../AppModel'; + +/** Tool call info for display in the chat UI. */ +export interface ToolCallDisplay { + name: string; + input: Record; + result: string; + isError: boolean; +} + +/** A message for display, including a "thinking" placeholder state. */ +export interface DisplayMessage { + role: 'user' | 'assistant'; + content: string; + thinking?: boolean; + toolCalls?: ToolCallDisplay[]; + /** Elapsed time in ms for the full LLM generate cycle (including tool loops). */ + elapsedMs?: number; +} + +/** Max tool-use loop iterations to prevent runaway. */ +const MAX_TOOL_ITERATIONS = 5; + +/** + * Model for the LLM chat harness — manages conversation history, + * LLM API calls, tool execution loop, and typewriter display effect. + * + * Dashboard changes are handled by dashboard tools in LlmToolService, which + * execute within the tool-use loop alongside app operation tools. + */ +export class ChatHarnessModel extends HoistModel { + @bindable userInput: string = ''; + @observable.ref messages: ChatMessage[] = []; + @observable.ref displayMessages: DisplayMessage[] = []; + @observable.ref lastError: string = null; + + // Typewriter effect state + @observable typingMessageIdx: number = -1; + @observable typingChars: number = 0; + + @managed + generateTask = TaskObserver.trackLast(); + + private _typingTimer: ReturnType = null; + // Synchronous guard against concurrent sends — complements the MobX-based + // generateTask.isPending check which can miss rapid-fire calls from the + // same tick due to batching. + private _isGenerating = false; + + constructor() { + super(); + makeObservable(this); + } + + /** + * Get displayed content for a message, accounting for typewriter effect. + * Call with the formatted (JSON-stripped) content, not the raw LLM text. + */ + getDisplayContent(index: number, formattedContent: string): string { + if (index === this.typingMessageIdx && this.typingChars < formattedContent.length) { + return formattedContent.slice(0, this.typingChars); + } + return formattedContent; + } + + /** Whether the typewriter effect is currently animating. */ + @computed + get isTyping(): boolean { + return this.typingMessageIdx >= 0; + } + + /** Send the current user input (or provided text) to the LLM. */ + @action + async sendMessageAsync(content?: string) { + const input = content ?? this.userInput; + if (!input.trim() || this._isGenerating) return; + this._isGenerating = true; + + const userMsg: ChatMessage = {role: 'user', content: input.trim()}; + this.messages = [...this.messages, userMsg]; + this.rebuildDisplayMessages(); + this.userInput = ''; + this.lastError = null; + + this.doGenerateAsync() + .finally(() => (this._isGenerating = false)) + .linkTo(this.generateTask); + } + + /** Retry the last user message after an error. */ + @action + retryLastAsync() { + if (!this.lastError || this._isGenerating) return; + this._isGenerating = true; + this.lastError = null; + this.doGenerateAsync() + .finally(() => (this._isGenerating = false)) + .linkTo(this.generateTask); + } + + /** Pop the last user message back into the input for editing after an error. */ + @action + editLast() { + if (!this.lastError || this.generateTask.isPending) return; + const msgs = this.messages; + if (msgs.length && msgs[msgs.length - 1].role === 'user') { + const lastContent = msgs[msgs.length - 1].content; + this.userInput = typeof lastContent === 'string' ? lastContent : ''; + this.messages = msgs.slice(0, -1); + this.rebuildDisplayMessages(); + } + this.lastError = null; + } + + /** Clear the conversation. */ + @action + clearChat() { + this.stopTyping(); + this.messages = []; + this.displayMessages = []; + this.lastError = null; + } + + override destroy() { + this.stopTyping(); + super.destroy(); + } + + //------------------ + // Implementation + //------------------ + private async doGenerateAsync() { + const startTime = Date.now(); + try { + const chatSvc = XH.llmChatService, + toolSvc = XH.llmToolService; + + const currentSpec = this.getCurrentSpec(); + const systemPrompt = chatSvc.buildSystemPrompt(currentSpec); + const tools = toolSvc.getToolDefinitions(); + + // Accumulate tool calls across iterations for display + const allToolCalls: ToolCallDisplay[] = []; + let allTextParts: string[] = []; + + // Tool use loop: call LLM, execute any tools, send results back + for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) { + const {content} = await chatSvc.generateAsync(systemPrompt, this.messages, tools); + + // Append assistant message with full content blocks to API history + runInAction(() => { + this.messages = [...this.messages, {role: 'assistant', content}]; + }); + + // Collect text parts from this response + const textParts = content.filter(b => b.type === 'text' && b.text).map(b => b.text); + allTextParts.push(...textParts); + + // Check for tool use blocks — break if none + const toolUseBlocks = content.filter(b => b.type === 'tool_use'); + if (toolUseBlocks.length === 0) break; + + // Execute each tool and collect results + const toolResults: ContentBlock[] = []; + for (const block of toolUseBlocks) { + const {content: result, isError} = await toolSvc.executeToolAsync( + block.name, + block.input + ); + allToolCalls.push({ + name: block.name, + input: block.input, + result, + isError + }); + toolResults.push({ + type: 'tool_result', + tool_use_id: block.id, + content: result, + is_error: isError + }); + } + + // Send tool results back as a user message + runInAction(() => { + this.messages = [...this.messages, {role: 'user', content: toolResults}]; + }); + } + + // Build final display text from all text parts + const finalText = allTextParts.join('\n\n'); + + // Single consolidated toast if any dashboard-mutating tools were called + const DASH_TOOLS = new Set([ + 'set_dashboard', + 'add_widget', + 'remove_widget', + 'update_widget', + 'move_widget' + ]); + const dashToolCount = allToolCalls.filter( + tc => DASH_TOOLS.has(tc.name) && !tc.isError + ).length; + if (dashToolCount > 0) { + XH.successToast('Dashboard updated.'); + } + + runInAction(() => { + // Add display message with tool calls + text + elapsed time + const elapsedMs = Date.now() - startTime; + this.displayMessages = [ + ...this.displayMessages, + { + role: 'assistant', + content: finalText, + toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined, + elapsedMs + } + ]; + + this.startTyping(this.displayMessages.length - 1, finalText); + }); + } catch (e) { + runInAction(() => { + this.lastError = e.message || 'LLM request failed.'; + }); + } + } + + /** + * Rebuild displayMessages from the API messages array. + * Called when user messages are added/removed (send, edit). + * Only processes user messages — assistant display messages are added + * by doGenerateAsync after the full tool loop completes. + */ + @action + private rebuildDisplayMessages() { + const display: DisplayMessage[] = []; + for (const msg of this.messages) { + if (msg.role === 'user' && typeof msg.content === 'string') { + display.push({role: 'user', content: msg.content}); + } + // Assistant display messages and tool-result user messages are not + // rebuilt here — they're managed by doGenerateAsync. + } + + // Preserve existing assistant display messages by interleaving: + // Walk through existing displayMessages to keep assistant entries intact. + const existing = this.displayMessages; + const merged: DisplayMessage[] = []; + let userIdx = 0; + const userMsgs = display; + + for (const dm of existing) { + if (dm.role === 'user') { + if (userIdx < userMsgs.length) { + merged.push(userMsgs[userIdx++]); + } + } else { + merged.push(dm); + } + } + // Append any remaining new user messages + while (userIdx < userMsgs.length) { + merged.push(userMsgs[userIdx++]); + } + + this.displayMessages = merged; + } + + private getCurrentSpec(): DashSpec | undefined { + try { + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + const persistable = dashModel.getPersistableState(); + return {version: 1, state: persistable?.value?.state ?? []}; + } catch { + return undefined; + } + } + + //------------------ + // Typewriter + //------------------ + @action + private startTyping(index: number, rawContent: string) { + this.stopTyping(); + const formatted = formatMessageContent(rawContent); + // Skip animation for very short messages + if (formatted.length < 30) return; + + this.typingMessageIdx = index; + this.typingChars = 0; + + // Target ~1.2s total animation; tick every 12ms + const charsPerTick = Math.max(3, Math.ceil(formatted.length / 100)); + this._typingTimer = setInterval(() => { + runInAction(() => { + this.typingChars += charsPerTick; + if (this.typingChars >= formatted.length) { + this.stopTyping(); + } + }); + }, 12); + } + + private stopTyping() { + if (this._typingTimer) { + clearInterval(this._typingTimer); + this._typingTimer = null; + } + if (this.typingMessageIdx >= 0) { + runInAction(() => { + this.typingMessageIdx = -1; + this.typingChars = 0; + }); + } + } +} + +/** + * Format a message content string, stripping JSON code fences for cleaner display. + * Exported for use by both model (typewriter length) and panel (render). + */ +export function formatMessageContent(content: string): string { + return content.replace(/```json[\s\S]*?```/g, '[Dashboard spec applied]').trim(); +} diff --git a/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts new file mode 100644 index 000000000..626cf3ae4 --- /dev/null +++ b/client-app/src/examples/weatherv2/harness/ChatHarnessPanel.ts @@ -0,0 +1,391 @@ +import {createElement} from 'react'; +import {creates, hoistCmp} from '@xh/hoist/core'; +import {div, hbox, vbox} from '@xh/hoist/cmp/layout'; +import {markdown} from '@xh/hoist/cmp/markdown'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {jsonInput, textArea} from '@xh/hoist/desktop/cmp/input'; +import {Icon} from '@xh/hoist/icon'; +import {sparklesIcon} from '../Icons'; +import { + ChatHarnessModel, + DisplayMessage, + ToolCallDisplay, + formatMessageContent +} from './ChatHarnessModel'; + +export const chatHarnessPanel = hoistCmp.factory({ + displayName: 'ChatHarnessPanel', + model: creates(ChatHarnessModel), + + render({model}) { + return panel({ + testId: 'chat-panel', + title: 'Dashboard Agent', + icon: sparklesIcon(), + compactHeader: true, + flex: 1, + minHeight: 0, + headerItems: [ + button({ + testId: 'chat-clear-btn', + icon: Icon.reset(), + tooltip: 'Clear conversation', + onClick: () => model.clearChat() + }) + ], + item: vbox({ + className: 'weather-v2-chat-body', + flex: 1, + items: [messageList(), errorDisplay(), chatInput()] + }) + }); + } +}); + +//-------------------------------------------------- +// Message List +//-------------------------------------------------- +const messageList = hoistCmp.factory({ + render({model}) { + const {displayMessages, generateTask} = model; + + // Append a thinking placeholder when waiting for an LLM response + const msgs = [...displayMessages]; + if ( + generateTask.isPending && + (msgs.length === 0 || msgs[msgs.length - 1].role === 'user') + ) { + msgs.push({role: 'assistant', content: '', thinking: true}); + } + + if (msgs.length === 0) { + return emptyState(); + } + + return div({ + className: 'weather-v2-chat-messages', + items: msgs.map((msg, i) => renderBubble(model, msg, i)), + ref: (el: HTMLElement) => { + if (el) el.scrollTop = el.scrollHeight; + } + }); + } +}); + +/** Render a single chat bubble — thinking placeholder, user, or assistant. */ +function renderBubble(model: ChatHarnessModel, msg: DisplayMessage, index: number) { + if (msg.thinking) { + return div({ + key: `thinking-${index}`, + className: + 'weather-v2-chat-msg weather-v2-chat-msg--assistant weather-v2-chat-msg--thinking', + items: [ + div({className: 'weather-v2-chat-msg__role', item: 'Assistant'}), + div({ + className: 'weather-v2-chat-msg__content', + item: div({ + className: 'weather-v2-thinking-dots', + items: [ + div({className: 'weather-v2-thinking-dot'}), + div({className: 'weather-v2-thinking-dot'}), + div({className: 'weather-v2-thinking-dot'}) + ] + }) + }) + ] + }); + } + + const formatted = formatMessageContent(msg.content); + const displayed = model.getDisplayContent(index, formatted); + const isTyping = index === model.typingMessageIdx; + const isAssistant = msg.role === 'assistant'; + + // Render completed assistant messages as markdown; use plain text during + // typewriter animation (partial markdown would produce broken rendering) + // and for user messages. + const contentItem = + isAssistant && !isTyping + ? div({ + className: 'weather-v2-chat-markdown', + item: markdown({content: displayed, lineBreaks: false}) + }) + : displayed; + + return div({ + key: index, + className: `weather-v2-chat-msg weather-v2-chat-msg--${msg.role}`, + items: [ + div({ + className: 'weather-v2-chat-msg__role', + items: [ + msg.role === 'user' ? 'You' : 'Assistant', + msg.elapsedMs + ? div({ + className: 'weather-v2-chat-msg__elapsed', + item: formatElapsed(msg.elapsedMs) + }) + : null + ] + }), + // Tool calls rendered between role label and text content + ...(msg.toolCalls?.length ? [renderToolCalls(msg.toolCalls)] : []), + div({ + className: `weather-v2-chat-msg__content${isTyping ? ' weather-v2-chat-msg__content--typing' : ''}`, + item: contentItem + }) + ] + }); +} + +/** Render tool call summary cards (collapsed by default using native details/summary). */ +function renderToolCalls(toolCalls: ToolCallDisplay[]) { + return div({ + className: 'weather-v2-tool-calls', + items: toolCalls.map((tc, i) => + createElement( + 'details', + {key: `tool-${i}`, className: 'weather-v2-tool-call'}, + createElement( + 'summary', + {className: 'weather-v2-tool-call__summary'}, + Icon.tools(), + friendlyToolSummary(tc) + ), + div({ + className: 'weather-v2-tool-call__detail', + items: [ + renderToolPayload('Input', tc.input), + renderToolPayload('Result', tc.result, tc.isError) + ] + }) + ) + ) + }); +} + +/** + * Render a tool call payload (input or result). Small values render inline; + * large JSON payloads render in a readonly jsonInput with search and copy. + */ +function renderToolPayload(label: string, value: any, isError?: boolean): any { + if (value == null) return null; + + const isObject = typeof value === 'object'; + const str = isObject ? JSON.stringify(value, null, 2) : String(value); + + // Empty objects don't need display + if (isObject && Object.keys(value).length === 0) return null; + + // Only large JSON objects get the jsonInput treatment + if (isObject && str.length >= 120) { + return div({ + className: isError ? 'weather-v2-tool-call__error' : null, + items: [ + div({item: `${label}:`}), + jsonInput({ + value: str, + readonly: true, + showCopyButton: true, + showFullscreenButton: true, + editorProps: {lineNumbers: false}, + width: '100%', + minHeight: 240 + }) + ] + }); + } + + // Everything else renders inline + return div({ + className: isError ? 'weather-v2-tool-call__error' : null, + item: `${label}: ${isObject ? JSON.stringify(value) : str}` + }); +} + +/** Format elapsed milliseconds into a friendly human-readable string. */ +function formatElapsed(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const secs = ms / 1000; + if (secs < 60) return `${secs.toFixed(1)}s`; + const mins = Math.floor(secs / 60); + const remainSecs = Math.round(secs % 60); + return `${mins}m ${remainSecs}s`; +} + +/** Map tool name + input to a human-readable summary. */ +function friendlyToolSummary(tc: ToolCallDisplay): string { + switch (tc.name) { + // Dashboard tools + case 'set_dashboard': + return 'set_dashboard'; + case 'add_widget': + return `add_widget: ${tc.input?.viewSpecId ?? ''}`; + case 'remove_widget': + return `remove_widget: ${tc.input?.instanceId ?? ''}`; + case 'update_widget': + return `update_widget: ${tc.input?.instanceId ?? ''}`; + case 'move_widget': + return `move_widget: ${tc.input?.instanceId ?? ''}`; + case 'list_widgets': + return 'list_widgets'; + // App operation tools + case 'save_dashboard_as_view': + return `save_view: ${tc.input?.name ?? ''}`; + case 'switch_to_view': + return `switch_to_view: ${tc.input?.name ?? ''}`; + case 'reset_dashboard': + return 'reset_dashboard'; + case 'toggle_theme': + return 'toggle_theme'; + case 'open_widget_chooser': + return 'open_widget_chooser'; + case 'show_json_spec': + return 'show_json_spec'; + case 'toggle_manual_editing': + return 'toggle_manual_editing'; + default: + return tc.name; + } +} + +//-------------------------------------------------- +// Empty State with Suggestions +//-------------------------------------------------- +const SUGGESTIONS = [ + 'Compare weather in New York and Tokyo side by side', + 'Show current conditions for 16 major cities in a 4x4 grid', + 'Set up a Tokyo weather dashboard and save it as a view called "Tokyo Overview"', + 'Simplify to just current conditions and the 5-day forecast' +]; + +const META_SUGGESTIONS = [ + 'What can you help me do?', + 'How do I change which city my dashboard shows?' +]; + +const emptyState = hoistCmp.factory({ + render({model}) { + return div({ + className: 'weather-v2-chat-empty', + items: [ + div({ + className: 'weather-v2-chat-empty__intro', + items: [ + sparklesIcon({size: '2x'}), + div({ + className: 'weather-v2-chat-empty__text', + item: 'Tell the agent what to build and it will create or update your dashboard.' + }) + ] + }), + div({ + className: 'weather-v2-chat-empty__suggestions', + items: [ + div({ + className: 'weather-v2-chat-empty__suggestions-label', + item: 'Try asking:' + }), + ...SUGGESTIONS.map(suggestion => + div({ + className: 'weather-v2-chat-suggestion', + item: suggestion, + onClick: () => model.sendMessageAsync(suggestion) + }) + ) + ] + }), + div({ + className: 'weather-v2-chat-empty__suggestions', + items: [ + div({ + className: 'weather-v2-chat-empty__suggestions-label', + item: 'Or ask about the agent:' + }), + ...META_SUGGESTIONS.map(suggestion => + div({ + className: + 'weather-v2-chat-suggestion weather-v2-chat-suggestion--meta', + item: suggestion, + onClick: () => model.sendMessageAsync(suggestion) + }) + ) + ] + }) + ] + }); + } +}); + +//-------------------------------------------------- +// Error Display +//-------------------------------------------------- +const errorDisplay = hoistCmp.factory({ + render({model}) { + const {lastError} = model; + if (!lastError) return null; + + return div({ + className: 'weather-v2-chat-error', + items: [ + div({className: 'weather-v2-chat-error__message', item: lastError}), + hbox({ + className: 'weather-v2-chat-error__actions', + items: [ + button({ + icon: Icon.refresh(), + text: 'Retry', + intent: 'danger', + outlined: true, + onClick: () => model.retryLastAsync() + }), + button({ + icon: Icon.edit(), + text: 'Edit', + outlined: true, + onClick: () => model.editLast() + }) + ] + }) + ] + }); + } +}); + +//-------------------------------------------------- +// Chat Input +//-------------------------------------------------- +const chatInput = hoistCmp.factory({ + render({model}) { + const isPending = model.generateTask.isPending; + return div({ + className: 'weather-v2-chat-input', + items: [ + textArea({ + testId: 'chat-input', + bind: 'userInput', + placeholder: 'Tell the agent what to build...', + flex: 1, + height: 80, + commitOnChange: true, + disabled: isPending, + onKeyDown: (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + model.sendMessageAsync(); + } + } + }), + button({ + testId: 'chat-send-btn', + icon: Icon.chevronRight(), + text: 'Send', + intent: 'primary', + disabled: isPending || !model.userInput.trim(), + onClick: () => model.sendMessageAsync() + }) + ] + }); + } +}); diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts new file mode 100644 index 000000000..771eb8650 --- /dev/null +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessModel.ts @@ -0,0 +1,176 @@ +import {createRef} from 'react'; +import {HoistModel, PersistableState, XH} from '@xh/hoist/core'; +import {DashCanvasModel} from '@xh/hoist/desktop/cmp/dash'; +import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; +import {DashSpec, DashWidgetState, ValidationResult} from '../dash/types'; +import {validateSpec, migrateSpec} from '../dash/validation'; +import {EXAMPLE_SPECS, ExampleSpec} from '../dash/exampleSpecs'; +import {AppModel} from '../AppModel'; + +/** + * Model for the JSON harness — manages the editor state, validation, + * and the apply/export workflow. + */ +export class JsonHarnessModel extends HoistModel { + @bindable editorValue: string = ''; + @observable.ref lastValidation: ValidationResult = null; + @bindable lastError: string = null; + containerRef = createRef(); + + get exampleSpecs(): ExampleSpec[] { + return EXAMPLE_SPECS; + } + + /** True when editor content differs from the live dashboard state. */ + @computed + get isDiverged(): boolean { + try { + const editorNormalized = JSON.stringify(JSON.parse(this.editorValue)); + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + const dashSpec: DashSpec = {version: 1, state: this.buildSpecState(dashModel)}; + const dashNormalized = JSON.stringify(dashSpec); + return editorNormalized !== dashNormalized; + } catch { + // Unparseable editor content is always considered diverged. + return true; + } + } + + constructor() { + super(); + makeObservable(this); + this.syncFromDashboard(); + } + + /** Load current dashboard state into the editor. */ + @action + syncFromDashboard() { + try { + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + const spec: DashSpec = {version: 1, state: this.buildSpecState(dashModel)}; + this.editorValue = JSON.stringify(spec, null, 2); + this.lastValidation = null; + this.lastError = null; + } catch (e) { + this.editorValue = '{"version": 1, "state": []}'; + } + } + + /** Parse, migrate, validate, and apply the editor JSON to the dashboard. */ + @action + applySpec() { + this.lastError = null; + this.lastValidation = null; + + // Parse + let raw: any; + try { + raw = JSON.parse(this.editorValue); + } catch (e) { + this.lastError = `JSON parse error: ${e.message}`; + return; + } + + // Migrate + let spec: DashSpec; + try { + spec = migrateSpec(raw as DashSpec); + } catch (e) { + this.lastError = `Migration error: ${e.message}`; + return; + } + + // Validate + const result = validateSpec(spec); + this.lastValidation = result; + + if (!result.valid) return; + + // Apply to dashboard + try { + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + dashModel.setPersistableState(new PersistableState({state: spec.state})); + XH.successToast('Dashboard spec applied.'); + } catch (e) { + this.lastError = `Apply error: ${e.message}`; + } + } + + /** Load an example spec into the editor. */ + @action + loadExample(name: string) { + const example = EXAMPLE_SPECS.find(e => e.name === name); + if (example) { + this.editorValue = JSON.stringify(example.spec, null, 2); + this.lastValidation = null; + this.lastError = null; + } + } + + /** Validate without applying. */ + @action + validateOnly() { + this.lastError = null; + this.lastValidation = null; + + let raw: any; + try { + raw = JSON.parse(this.editorValue); + } catch (e) { + this.lastError = `JSON parse error: ${e.message}`; + return; + } + + let spec: DashSpec; + try { + spec = migrateSpec(raw as DashSpec); + } catch (e) { + this.lastError = `Migration error: ${e.message}`; + return; + } + + const result = validateSpec(spec); + if (result.valid && result.warnings.length === 0) { + XH.successToast({ + message: 'Spec is valid.', + containerRef: this.containerRef.current, + position: 'top' + }); + } else { + this.lastValidation = result; + } + } + + /** Clear validation/error display. */ + @action + dismissValidation() { + this.lastValidation = null; + this.lastError = null; + } + + //------------------------ + // Implementation + //------------------------ + /** + * Build the current DashSpec state from the DashCanvasModel's live viewModels and layout. + * + * Workaround for https://github.com/xh/hoist-react/issues/4276 — + * getPersistableState() can return stale initialState after persistence restore. + * Once that issue is resolved, this method and its callers could be simplified to + * use getPersistableState() directly. + */ + private buildSpecState(dashModel: DashCanvasModel): DashWidgetState[] { + return dashModel.layout + .map(({i: id, x, y, w, h}) => { + const vm = dashModel.viewModels.find(v => v.id === id); + if (!vm) return null; + return { + layout: {x, y, w, h}, + viewSpecId: vm.viewSpec.id, + title: vm.title, + state: vm.viewState + }; + }) + .filter(Boolean); + } +} diff --git a/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts new file mode 100644 index 000000000..1862cbd07 --- /dev/null +++ b/client-app/src/examples/weatherv2/harness/JsonHarnessPanel.ts @@ -0,0 +1,148 @@ +import {creates, hoistCmp} from '@xh/hoist/core'; +import {div, filler, vbox} from '@xh/hoist/cmp/layout'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {select} from '@xh/hoist/desktop/cmp/input'; +import {jsonInput} from '@xh/hoist/desktop/cmp/input'; +import {Icon} from '@xh/hoist/icon'; +import {JsonHarnessModel} from './JsonHarnessModel'; + +export const jsonHarnessPanel = hoistCmp.factory({ + displayName: 'JsonHarnessPanel', + model: creates(JsonHarnessModel), + + render({model}) { + return panel({ + testId: 'json-panel', + ref: model.containerRef, + title: 'JSON Spec Editor', + icon: Icon.code(), + compactHeader: true, + flex: 1, + minHeight: 0, + item: vbox({flex: 1, items: [editorArea(), validationDisplay()]}), + bbar: bottomToolbar() + }); + } +}); + +const editorArea = hoistCmp.factory({ + render() { + return jsonInput({ + testId: 'json-editor', + bind: 'editorValue', + flex: 1, + width: '100%', + commitOnChange: true, + enableSearch: true, + showCopyButton: true, + showFormatButton: true, + showFullscreenButton: true + }); + } +}); + +const validationDisplay = hoistCmp.factory({ + render({model}) { + const {lastValidation, lastError} = model; + + if (!lastValidation && !lastError) return null; + + let icon, title, className; + if (lastError) { + icon = Icon.warning(); + title = 'Error'; + className = 'weather-v2-validation--error'; + } else if (!lastValidation.valid) { + icon = Icon.warning(); + title = `${lastValidation.errors.length} error(s), ${lastValidation.warnings.length} warning(s)`; + className = 'weather-v2-validation--error'; + } else { + icon = Icon.warning(); + title = `Valid with ${lastValidation.warnings.length} warning(s)`; + className = 'weather-v2-validation--warning'; + } + + const messages = lastError + ? [lastError] + : [...(lastValidation?.errors ?? []), ...(lastValidation?.warnings ?? [])]; + + return panel({ + icon, + title, + className: `weather-v2-validation ${className}`, + compactHeader: true, + maxHeight: 200, + headerItems: [ + button({ + icon: Icon.close(), + minimal: true, + small: true, + onClick: () => model.dismissValidation() + }) + ], + item: div({ + className: 'weather-v2-validation-details', + style: {overflow: 'auto'}, + items: messages.slice(0, 10).map((m, i) => + div({ + key: i, + className: `weather-v2-validation-msg weather-v2-validation-msg--${typeof m === 'string' ? 'error' : m.level}`, + item: + typeof m === 'string' + ? m + : `[${m.level.toUpperCase()}] ${m.path ? m.path + ': ' : ''}${m.message}` + }) + ) + }) + }); + } +}); + +const bottomToolbar = hoistCmp.factory({ + render({model}) { + const exampleOptions = model.exampleSpecs.map(e => ({ + value: e.name, + label: e.name + })); + + return toolbar( + select({ + testId: 'json-load-example', + options: exampleOptions, + placeholder: 'Load Example...', + width: 180, + enableFilter: false, + onChange: (val: string) => { + if (val) model.loadExample(val); + } + }), + button({ + testId: 'json-sync-btn', + icon: Icon.sync(), + text: 'Sync from Dash', + tooltip: 'Overwrite editor with current dashboard state', + disabled: !model.isDiverged, + onClick: () => model.syncFromDashboard() + }), + filler(), + button({ + testId: 'json-validate-btn', + icon: Icon.check(), + text: 'Validate', + onClick: () => model.validateOnly() + }), + '-', + button({ + testId: 'json-apply-btn', + icon: Icon.play(), + text: 'Apply to Dash', + tooltip: 'Apply editor spec to the dashboard', + intent: 'success', + disabled: !model.isDiverged, + onClick: () => model.applySpec() + }) + ); + } +}); diff --git a/client-app/src/examples/weatherv2/planning/DEMO-SCRIPTS.md b/client-app/src/examples/weatherv2/planning/DEMO-SCRIPTS.md new file mode 100644 index 000000000..e41267faf --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/DEMO-SCRIPTS.md @@ -0,0 +1,106 @@ +# Demo Scripts — Customer "Wow" Scenarios + +These are exact chat prompts for the LLM harness, with expected resulting dashboards. Each demonstrates a key V2 capability. + +--- + +## Demo 1: "Build me a dashboard from scratch" + +**Prompt:** +> Build me a weather dashboard for New York. I want to see current conditions, a temperature forecast chart, and precipitation data. + +**Expected Result:** + +Dashboard with 4 widgets: +- City Chooser (top-left, 3×2) — pre-set to "New York" +- Current Conditions (top-right, 4×5) — bound to city chooser, showing temperature gauge + details +- Forecast Chart (middle-left, 6×5) — bound to city chooser, series: temp + feelsLike, line chart +- Precipitation Chart (middle-right, 6×5) — bound to city chooser, showing probability + volume + +All display widgets wired to the city chooser. User can immediately switch cities and see all widgets update. + +**What it demonstrates:** LLM understands widget types, creates appropriate bindings, produces valid layout. + +--- + +## Demo 2: "Compare two cities" + +**Prompt (starting from Demo 1 result):** +> I want to compare New York and London side by side. Add a second city chooser for London and duplicate the current conditions and forecast chart for the London side. + +**Expected Result:** + +Dashboard with 7 widgets, organized in two columns: +- Left column: City Chooser A ("New York") + Current Conditions A + Forecast Chart A +- Right column: City Chooser B ("London") + Current Conditions B + Forecast Chart B +- Bottom: Precipitation chart (still bound to City A) + +The two City Choosers are independent. Conditions and Forecast widgets in the left column bind to Chooser A; right column binds to Chooser B. Changing either chooser updates only its column. + +**What it demonstrates:** Multiple instances of the same widget type, independent binding graphs, small multiples pattern. + +--- + +## Demo 3: "Add units and customize" + +**Prompt (starting from Demo 2 result):** +> Add a units toggle set to metric. Connect it to all the display widgets. Also add a title bar that says "Global Weather Comparison Dashboard." + +**Expected Result:** + +Dashboard gains: +- Units Toggle widget (top center, 3×2) — set to "metric" +- Markdown Content widget (full width at top, 12×2) — renders "# Global Weather Comparison Dashboard" +- All display widgets gain a `units` binding pointing to the Units Toggle +- Temperature values now show in Celsius, wind in m/s + +**What it demonstrates:** Iterative editing — LLM modifies existing spec, adds new widgets, updates bindings without breaking existing wiring. Also shows utility widgets (markdown, units toggle). + +--- + +## Demo 4: "Show me the internals" + +**Prompt (starting from any dashboard):** +> Add a Dashboard Inspector widget on the right side so I can see how the widgets are wired together. + +**Expected Result:** + +Dashboard Inspector widget (right column, 4×8) appears showing: +- List of all widget instances with their IDs and types +- For each widget: its input bindings (resolved to current values) and output values +- Validation status (all green if spec is valid) + +The inspector updates live as the user interacts — changing city in a chooser shows the new value propagating through bindings. + +**What it demonstrates:** The IO/wiring model is transparent and debuggable. Customers can see exactly how data flows through the dashboard. + +--- + +## Demo 5: "The JSON is the API" + +**Scenario:** Demo the JSON harness directly — no LLM. + +1. **Start with empty dashboard.** Click "Load Example" → select "City Comparison" preset. +2. **Dashboard hydrates** with the two-city comparison layout. +3. **Click "View Spec"** — JSON editor panel shows the full dashboard spec. +4. **Edit the JSON directly:** Change `"selectedCity": "New York"` to `"selectedCity": "Tokyo"`. Click "Apply." +5. **Dashboard updates** — left column now shows Tokyo weather. +6. **Add a widget via JSON:** Copy a `forecastChart` widget instance, paste it with a new layout position and different series config (`["humidity", "pressure"]`). Apply. +7. **New chart appears** in the dashboard showing humidity and pressure data. +8. **Export spec** — click "Copy Spec" to clipboard. This is the full, portable dashboard definition. + +**Pitch:** "This JSON spec is the entire dashboard — layout, widgets, configuration, and wiring. Any Hoist application can hydrate it. An LLM can generate it. A human can edit it. It's persisted, shareable, and versioned. This is dashboards as code." + +**What it demonstrates:** The spec is tangible, human-readable, and round-trips perfectly between JSON and live dashboard. The JSON harness is useful even without LLM integration. + +--- + +## Presentation Flow (Recommended Order) + +1. **Demo 5 first** — establish that the JSON spec is real and tangible. +2. **Demo 1** — show the LLM can build a dashboard from natural language. +3. **Demo 2** — show iterative editing and compositional power. +4. **Demo 3** — show the breadth of widget types and wiring flexibility. +5. **Demo 4** — pull back the curtain on the wiring model. + +This progression goes: "here's the format" → "an LLM can write it" → "it handles complex compositions" → "and it's all transparent." diff --git a/client-app/src/examples/weatherv2/planning/DEPLOYMENT-MEMO.md b/client-app/src/examples/weatherv2/planning/DEPLOYMENT-MEMO.md new file mode 100644 index 000000000..cee74614f --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/DEPLOYMENT-MEMO.md @@ -0,0 +1,242 @@ +# Deployment & LLM Integration Memo + +## Decision Summary + +| Aspect | Decision | Rationale | +|--------|----------|-----------| +| **Primary LLM provider** | Anthropic (Claude) | Best structured output quality, strong JSON adherence, good at following schemas | +| **Fallback provider** | OpenAI (GPT-4o) | Widely available, proven JSON mode | +| **Phase A: JSON harness** | No LLM integration | Human relays specs via external LLM. Validates schema + prompt strategy with zero integration work | +| **Phase B: Client-side POC** | Direct Anthropic API calls from TypeScript | User-provided API key in localStorage. Good for local dev and internal demos | +| **Phase C: Deployed proxy** | Grails controller + service | Server-stored API key via Hoist Config (pwd type). Required for public Toolbox deployment | +| **Recommended path** | Build Phase B first, add Phase C for production | Client-side is faster to iterate; server proxy is a straightforward addition when needed | + +## Architecture Options Evaluated + +### Option 1: Client-Side Only (Phase B) + +**How it works:** TypeScript code calls the Anthropic Messages API directly from the browser using the Anthropic JS SDK (`@anthropic-ai/sdk`). The API key is provided by the user and stored in localStorage. + +**Pros:** +- No Grails changes — purely a client-side feature. +- Fast iteration cycle — change prompts, test immediately. +- Works for any developer with their own API key. + +**Cons:** +- API key in the browser is unacceptable for production. +- CORS: Anthropic's API does not support browser-originated requests without a proxy. This is a **blocker** for truly client-side-only calls. +- No rate limiting, cost control, or audit logging. + +**CORS Reality Check:** The Anthropic API does not set `Access-Control-Allow-Origin` headers for browser requests. Direct client-side calls will fail due to CORS. This means even Phase B needs *some* form of proxy. Options: +- A simple CORS proxy (e.g., a small Express server or Cloudflare Worker) — adds infra complexity. +- Use the Grails backend as the proxy from the start — the path of least resistance. + +**Verdict:** Client-side-only is not viable for browser-originated calls due to CORS. However, the *prompt logic and response handling* should be client-side TypeScript. The server just relays. + +### Option 2: Grails Proxy (Phase C) + +**How it works:** A new `LlmController` + `LlmService` in the Grails backend. The client sends a structured request (prompt + current spec + widget schemas). The server calls the Anthropic API, validates the response, and returns the result. + +**Pros:** +- API key stored securely in Hoist Config (pwd-encrypted, not in client bundle). +- Natural rate limiting via Hoist's request pipeline. +- Audit logging of prompts + responses for debugging. +- Works for deployed Toolbox — the production path. +- Reuses Hoist's `JSONClient` for outbound HTTP. + +**Cons:** +- Requires Grails changes (controller + service + config). +- Adds a network hop and server-side latency. +- Server must be running for LLM features to work. + +**Verdict:** This is the right production architecture. The Grails backend is already running for weather data — adding an LLM proxy is a natural extension. + +### Option 3: Hybrid — Thin Proxy, Client-Side Logic + +**How it works:** The Grails proxy is intentionally thin — it just relays requests to the LLM provider with the API key injected. All prompt construction, response parsing, validation, and retry logic lives in client-side TypeScript. + +**Pros:** +- Keeps the "intelligence" client-side where iteration is fast. +- Server changes are minimal and stable (no prompt logic on server). +- API key is secure; everything else is in the client. + +**Cons:** +- Slightly more client-side complexity. +- Full prompt text travels over the wire (not a real concern for internal/demo use). + +**Verdict:** This is the recommended approach. It combines the security of server-side key management with the iteration speed of client-side prompt engineering. + +## Recommended Architecture: Thin Grails Proxy + +### Server Side + +**LlmController.groovy:** +```groovy +@AccessAll +class LlmController extends BaseController { + def llmService + + def generate() { + def body = parseRequestJSON() + renderJSON(llmService.generate(body.messages, body.config)) + } +} +``` + +**LlmService.groovy:** +```groovy +class LlmService extends BaseService { + static clearCachesConfigs = ['llmApiKey', 'llmProvider'] + + def configService + private JSONClient _client + + Map generate(List messages, Map config = [:]) { + def provider = configService.getString('llmProvider', 'anthropic') + def apiKey = configService.getPwd('llmApiKey') + def model = config.model ?: configService.getString('llmModel', 'claude-sonnet-4-20250514') + def maxTokens = config.maxTokens ?: configService.getInt('llmMaxTokens', 4096) + + if (!apiKey || apiKey == 'UNCONFIGURED') { + throw new DataNotAvailableException( + 'LLM API key not configured. Set "llmApiKey" in Admin console.' + ) + } + + def payload = buildPayload(provider, messages, model, maxTokens) + def url = getApiUrl(provider) + + def post = new HttpPost(url) + post.setHeader('Authorization', "Bearer ${apiKey}") + // Anthropic uses x-api-key header and anthropic-version header + if (provider == 'anthropic') { + post.setHeader('x-api-key', apiKey) + post.setHeader('anthropic-version', '2023-06-01') + post.removeHeaders('Authorization') + } + post.setEntity(new StringEntity(JSONSerializer.serialize(payload))) + + return client.executeAsMap(post) + } + + void clearCaches() { + _client = null + super.clearCaches() + } +} +``` + +**Hoist Config entries:** +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `llmApiKey` | pwd | `UNCONFIGURED` | Anthropic API key | +| `llmProvider` | string | `anthropic` | Provider: `anthropic` or `openai` | +| `llmModel` | string | `claude-sonnet-4-20250514` | Model identifier | +| `llmMaxTokens` | int | `4096` | Max response tokens | + +### Client Side + +**LlmService.ts** (client-side TypeScript service or utility): +```typescript +class LlmChatService { + /** Send a prompt to the LLM proxy and return the response. */ + async generateAsync( + systemPrompt: string, + messages: ChatMessage[], + config?: {model?: string; maxTokens?: number} + ): Promise { + const response = await XH.postJson({ + url: 'llm/generate', + body: { + messages: [ + {role: 'system', content: systemPrompt}, + ...messages + ], + config + } + }); + return this.parseResponse(response); + } + + /** Build the system prompt with widget schemas and current spec. */ + buildSystemPrompt(currentSpec?: DashSpec): string { + const schemaText = widgetRegistry.generateLLMPrompt(); + const specText = currentSpec ? JSON.stringify(currentSpec, null, 2) : 'No dashboard yet.'; + return SYSTEM_PROMPT_TEMPLATE + .replace('{{WIDGET_SCHEMAS}}', schemaText) + .replace('{{CURRENT_SPEC}}', specText); + } +} +``` + +## Prompt Strategy + +### System Prompt Structure + +``` +You are a dashboard builder assistant. You create and modify weather dashboard specifications. + +## Dashboard Spec Format +The dashboard is defined as a JSON object with a "state" array of widget instances. +Each widget has: viewSpecId, layout {x, y, w, h}, optional title, and state (config + bindings). + +## Grid System +The dashboard uses a 12-column grid. Widgets are positioned by column (x) and row (y), +with width (w) in columns and height (h) in rows. + +## Available Widget Types +{{WIDGET_SCHEMAS}} + +## Wiring +Widgets communicate via bindings. Display widgets can bind their inputs to +the outputs of input widgets using: {"fromWidget": "", "output": ""} +Or use constant values: {"const": ""} + +Widget instance IDs are assigned in order: first instance of type X gets ID "X", +second gets "X_2", etc. + +## Current Dashboard +{{CURRENT_SPEC}} + +## Instructions +- Respond with ONLY a valid JSON dashboard spec. No explanation, no markdown fences. +- When modifying an existing dashboard, preserve widgets the user didn't mention. +- Always include valid bindings for display widgets. +- Use sensible defaults for unspecified config properties. +- Keep layouts non-overlapping and well-organized. +``` + +### Edit Protocol + +For iterative edits, the LLM receives the current spec and a user instruction. It returns a complete updated spec (not a patch). This is simpler and more reliable than JSON Patch: +- Full spec is easier for the LLM to produce correctly. +- Full spec can be validated as a unit. +- The client diffs old vs new for display purposes if needed. + +**Why not JSON Patch:** JSON Patch (`{op, path, value}` arrays) is harder for LLMs to produce correctly — path references are error-prone, operations must be ordered, and partial patches can leave the spec in an invalid intermediate state. Full-spec replacement is more reliable. + +## Rate Limiting & Cost Controls + +Given the low-volume, trusted audience (internal users, select customers, candidates): + +- **Per-user rate limit:** 20 requests per hour, enforced server-side via simple in-memory counter. Sufficient to prevent accidental runaway loops. +- **Max tokens per request:** Capped at 4096 (configurable via Hoist Config). Dashboard specs are typically < 2000 tokens. +- **Monthly cost estimate:** At ~20 users making ~10 requests/day at ~$0.01/request = ~$60/month. Well within acceptable demo budget. +- **No caching:** Each request is unique (user's specific dashboard + instruction). No caching value. + +## Latency Budget + +- **Acceptable:** 3-8 seconds for a full spec generation. +- **Target:** < 5 seconds for typical requests. +- **Anthropic Claude Sonnet:** Typical response time is 2-5 seconds for structured output of this size. Meets target. +- **UI:** Show a loading indicator with "Generating dashboard..." message. Stream response if possible (Anthropic supports streaming). + +## Phasing Summary + +| Phase | What | Blocked By | Effort | +|-------|------|------------|--------| +| **A: JSON Harness** | Manual copy-paste to external LLM | Nothing | Low — UI only | +| **B: Server Proxy** | Grails LlmController + LlmService + Config | Grails server running | Medium — new controller/service | +| **C: Chat Harness** | In-app chat UI + client-side prompt logic | Phase B | Medium — UI + prompt engineering | + +Phase A enables immediate validation of the schema and prompt strategy. Phase B adds the server infrastructure. Phase C wires it into a polished in-app experience. diff --git a/client-app/src/examples/weatherv2/planning/DSL-SPEC.md b/client-app/src/examples/weatherv2/planning/DSL-SPEC.md new file mode 100644 index 000000000..677bbe87d --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/DSL-SPEC.md @@ -0,0 +1,392 @@ +# Dashboard Spec Schema (DSL) + +## Core Principle + +The V2 "DSL" is **Hoist's native persisted state** — the JSON produced by `DashCanvasModel.getPersistableState()`. We don't invent a new format. We document the existing format, add wiring conventions to widget `viewState`, and provide a JSON Schema for validation. + +## State Structure + +`DashCanvasModel` persists as: + +```typescript +PersistableState<{state: DashCanvasItemState[]}> +``` + +Each item in the `state` array represents a widget instance: + +```typescript +interface DashCanvasItemState { + viewSpecId: string; // Widget type (maps to viewSpec.id) + layout: {x: number; y: number; w: number; h: number}; // Grid position + title?: string; // Custom display title (overrides viewSpec.title) + state?: Record; // Widget-specific persisted state +} +``` + +The V2 addition: widget `state` objects follow a convention where `bindings` is a reserved key for input wiring (see WIRING-DESIGN.md). All other keys are widget-specific config. + +## Full Dashboard Spec Shape + +```json +{ + "state": [ + { + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "title": "Select City", + "state": { + "selectedCity": "New York", + "enableSearch": true + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 3, "y": 0, "w": 9, "h": 5}, + "title": "Temperature Forecast", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"const": "imperial"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line" + } + } + ] +} +``` + +## JSON Schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WeatherDashboardV2Spec", + "description": "Dashboard specification for Weather Dashboard V2. This is the persisted state format for DashCanvasModel.", + "type": "object", + "required": ["state"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "description": "Spec version number for migration support.", + "default": 1, + "const": 1 + }, + "state": { + "type": "array", + "description": "Array of widget instances with their layout and configuration.", + "items": {"$ref": "#/$defs/widgetInstance"} + } + }, + "$defs": { + "widgetInstance": { + "type": "object", + "required": ["viewSpecId", "layout"], + "additionalProperties": false, + "properties": { + "viewSpecId": { + "type": "string", + "enum": [ + "cityChooser", "unitsToggle", "currentConditions", + "forecastChart", "precipChart", "windChart", + "summaryGrid", "markdownContent", "dashInspector" + ], + "description": "Widget type identifier." + }, + "layout": {"$ref": "#/$defs/layout"}, + "title": { + "type": "string", + "description": "Custom display title. Overrides the widget type's default title." + }, + "state": { + "type": "object", + "description": "Widget-specific configuration and bindings.", + "properties": { + "bindings": {"$ref": "#/$defs/bindingsMap"} + }, + "additionalProperties": true + } + } + }, + "layout": { + "type": "object", + "required": ["x", "y", "w", "h"], + "additionalProperties": false, + "properties": { + "x": {"type": "integer", "minimum": 0, "maximum": 11, "description": "Column position (0-indexed, max 11 for 12-col grid)."}, + "y": {"type": "integer", "minimum": 0, "description": "Row position (0-indexed)."}, + "w": {"type": "integer", "minimum": 1, "maximum": 12, "description": "Width in columns."}, + "h": {"type": "integer", "minimum": 1, "description": "Height in rows."} + } + }, + "bindingsMap": { + "type": "object", + "description": "Map of input name → binding spec. Keys are input names declared by the widget type.", + "additionalProperties": {"$ref": "#/$defs/bindingSpec"} + }, + "bindingSpec": { + "oneOf": [ + { + "type": "object", + "required": ["fromWidget", "output"], + "additionalProperties": false, + "properties": { + "fromWidget": {"type": "string", "description": "Instance ID of the source widget."}, + "output": {"type": "string", "description": "Name of the output on the source widget."} + }, + "description": "Bind to another widget's output." + }, + { + "type": "object", + "required": ["const"], + "additionalProperties": false, + "properties": { + "const": {"description": "A constant literal value."} + }, + "description": "Bind to a constant value." + } + ] + } + } +} +``` + +## Widget Instance ID Assignment + +`DashCanvasModel` assigns instance IDs automatically when loading state: the first instance of a viewSpec gets `viewSpecId` as its ID, subsequent instances get `viewSpecId_2`, `viewSpecId_3`, etc. + +This means **binding references use these generated IDs**. For a spec with two `cityChooser` instances: +- First: ID = `"cityChooser"` +- Second: ID = `"cityChooser_2"` + +Bindings in other widgets use these IDs: `{"fromWidget": "cityChooser_2", "output": "selectedCity"}`. + +**For LLM authoring:** The LLM must understand this ID assignment rule. The system prompt will explain: "Widget instance IDs are assigned in order of appearance in the `state` array. The first instance of type X gets ID `X`, the second gets `X_2`, etc. Use these IDs in bindings." + +## Validation Pipeline + +Validation runs in three stages before a spec is hydrated: + +### Stage 1: Structural (JSON Schema) + +Standard JSON Schema validation against the schema above. Catches: +- Missing required fields (`viewSpecId`, `layout`). +- Invalid `viewSpecId` values. +- Layout values out of bounds. +- Malformed binding specs. + +**Tool:** Use a JSON Schema validator library (e.g., `ajv`). + +### Stage 2: Semantic (Custom Validation) + +Post-schema checks that require knowledge of widget types: + +1. **Layout overlap detection.** Two widgets occupying the same grid cells. (Warning, not error — DashCanvas handles overlap via compaction.) +2. **Layout bounds.** Widget extends beyond column 12 (`x + w > 12`). (Error.) +3. **Config property validation.** For each widget, validate its `state` properties against the widget's `WidgetMeta.config`: + - Unknown config properties → warning. + - Invalid enum values → error. + - Type mismatches (string where number expected) → error. + - Missing required config → error (with default fallback). +4. **Binding input validation.** For each binding in a widget's `bindings` map: + - Input name must be declared in the widget's `WidgetMeta.inputs` → error if not. + - Required inputs without bindings → warning. + +### Stage 3: Referential (Graph Validation) + +Validates the wiring graph: + +1. **Dangling widget references.** `fromWidget` must reference a widget instance that exists in the spec. Compute expected IDs from the spec's widget order. +2. **Dangling output references.** `output` must be a declared output of the referenced widget type. +3. **Type compatibility.** The output's declared type must match the input's declared type. +4. **Cycle detection.** Build a directed graph (widget → widget via bindings). Run topological sort — reject if cycle detected. + +### Validation Output + +```typescript +interface ValidationResult { + valid: boolean; // true if no errors (warnings OK) + errors: ValidationMessage[]; // Must fix before hydration + warnings: ValidationMessage[]; // Informational, spec will work +} + +interface ValidationMessage { + level: 'error' | 'warning'; + path: string; // e.g., "state[2].state.bindings.city" + code: string; // e.g., "DANGLING_WIDGET_REF" + message: string; // Human-readable description +} +``` + +## Versioning & Migration + +### Version Field + +Specs carry a `version` field (default `1`). When the spec format changes: +1. Bump the version number. +2. Write a migration function: `migrateV1toV2(spec) → spec`. +3. On load, check version and apply migrations in sequence. + +### Migration Strategy + +Migrations are deterministic transformations applied in order: + +```typescript +const migrations: Record DashSpec> = { + // v1 → v2: example migration + 2: (spec) => ({ + ...spec, + version: 2, + state: spec.state.map(w => ({ + ...w, + state: { + ...w.state, + // v2 renames 'metric' to 'displayMetric' in precipChart + ...(w.viewSpecId === 'precipChart' && w.state?.metric + ? {displayMetric: w.state.metric, metric: undefined} + : {}) + } + })) + }) +}; + +function migrateSpec(spec: DashSpec): DashSpec { + let current = spec; + const target = CURRENT_VERSION; + while ((current.version ?? 1) < target) { + const next = (current.version ?? 1) + 1; + current = migrations[next](current); + } + return current; +} +``` + +For V1 (initial release): no migrations needed. The framework is in place for future evolution. + +## Hydration Flow + +``` +JSON string + ↓ parse +DashSpec object + ↓ migrateSpec() +DashSpec (current version) + ↓ validateSpec() +ValidationResult + ↓ if valid +DashCanvasModel.setPersistableState({state: spec.state}) + ↓ +Widgets instantiated, bindings resolved, data loaded +``` + +In the JSON harness, invalid specs show errors and keep the previous dashboard state. In the LLM harness, invalid specs trigger a repair prompt back to the LLM. + +## Example Full Dashboard Specs + +### Minimal: Single City Overview + +```json +{ + "version": 1, + "state": [ + { + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "state": {"selectedCity": "New York"} + }, + { + "viewSpecId": "currentConditions", + "layout": {"x": 3, "y": 0, "w": 4, "h": 5}, + "state": { + "bindings": {"city": {"fromWidget": "cityChooser", "output": "selectedCity"}} + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 7, "y": 0, "w": 5, "h": 5}, + "state": { + "bindings": {"city": {"fromWidget": "cityChooser", "output": "selectedCity"}}, + "series": ["temp"], + "chartType": "line" + } + } + ] +} +``` + +### Full: Comprehensive Dashboard + +```json +{ + "version": 1, + "state": [ + { + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "state": {"selectedCity": "New York"} + }, + { + "viewSpecId": "unitsToggle", + "layout": {"x": 3, "y": 0, "w": 3, "h": 2}, + "state": {"units": "imperial"} + }, + { + "viewSpecId": "currentConditions", + "layout": {"x": 6, "y": 0, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "displayMode": "detailed" + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 0, "y": 2, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line" + } + }, + { + "viewSpecId": "precipChart", + "layout": {"x": 0, "y": 7, "w": 6, "h": 5}, + "state": { + "bindings": {"city": {"fromWidget": "cityChooser", "output": "selectedCity"}}, + "metric": "both" + } + }, + { + "viewSpecId": "windChart", + "layout": {"x": 6, "y": 5, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "showGusts": true + } + }, + { + "viewSpecId": "summaryGrid", + "layout": {"x": 6, "y": 10, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + } + } + } + ] +} +``` + +### Comparison: Two Cities Side by Side + +See WIRING-DESIGN.md for the full multi-city comparison example. diff --git a/client-app/src/examples/weatherv2/planning/HOIST-CONVENTIONS.md b/client-app/src/examples/weatherv2/planning/HOIST-CONVENTIONS.md new file mode 100644 index 000000000..57f94640b --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/HOIST-CONVENTIONS.md @@ -0,0 +1,89 @@ +# Hoist Conventions Checklist — Weather Dashboard V2 + +This checklist codifies the "house style" expectations for V2 implementation. Every file written must pass these checks. + +## File & Directory Organization + +- [ ] V2 lives at `client-app/src/examples/weatherv2/` — completely separate from V1. +- [ ] Entry point at `client-app/src/apps/weatherv2.ts` using `XH.renderApp()`. +- [ ] Registered in `ExamplesTabModel.ts` alongside (not replacing) V1. +- [ ] One model class per file. Component may share the file if small, otherwise separate. +- [ ] Widget files live in `weatherv2/widgets/` — one file per widget (model + component together for simple widgets, split for complex ones). +- [ ] Shared types in `weatherv2/Types.ts`. Wiring/schema infrastructure in `weatherv2/dash/`. + +## Model Conventions + +- [ ] Models extend `HoistModel`. Call `makeObservable(this)` in every constructor that adds `@observable`, `@bindable`, or `@computed`. +- [ ] Use `@bindable` for properties that have corresponding UI inputs (auto-generates setter). +- [ ] Use `@observable.ref` for object/array references that are replaced wholesale. +- [ ] Use `@managed` on child models the class creates and owns. +- [ ] Use `@persist` + `persistWith` for properties that should survive page reload. +- [ ] Use `@lookup(() => DashViewModel)` in widget models to find parent view model in `onLinked()`. +- [ ] Defer `persistWith` and `markPersist()` calls to `onLinked()` (after context available). +- [ ] Implement `doLoadAsync(loadSpec)` for data loading — never call it directly, use `loadAsync()` / `refreshAsync()`. +- [ ] Check `loadSpec.isStale` after every await to avoid stale data writes. +- [ ] Use `addReaction()` for derived behavior (not manual subscriptions). + +## Component Conventions + +- [ ] Components use `hoistCmp.factory()` — no JSX. +- [ ] Declare model relationship: `creates(ModelClass)` (owns) or `uses(ModelClass)` (receives). +- [ ] Use Hoist element factories: `panel()`, `box()`, `vbox()`, `hbox()`, `grid()`, `button()`, etc. +- [ ] No JSX syntax anywhere. All UI via element factory functions. +- [ ] Keep render functions focused on structure — logic belongs in models. + +## Class Member Ordering + +1. `declare config` / static properties +2. Readonly / immutable properties +3. `@managed` children +4. `@observable` / `@bindable` state +5. `@computed` getters +6. Constructor +7. Lifecycle hooks (`onLinked`, `doLoadAsync`) +8. Public `@action` methods +9. Private implementation + +## Import Ordering + +1. External libraries (`lodash`, `react`) +2. `@xh/hoist` packages (grouped by subpackage) +3. Relative imports (local project files) + +## Code Style + +- [ ] Prettier: 100-char width, single quotes, no trailing commas, 4-space indent. +- [ ] No bracket spacing: `{foo}` not `{ foo }`. +- [ ] Arrow parens: avoid when possible (`x => x`). +- [ ] Semicolons: always. +- [ ] Destructure from model at top of methods: `const {selectedCity, forecast} = this;` + +## Dashboard / DashCanvas Conventions + +- [ ] `DashCanvasModel` configured with `viewSpecs` array and `persistWith: {viewManagerModel}`. +- [ ] Each `viewSpec` has: `id`, `title`, `icon`, `content` (factory function), `width`, `height`. +- [ ] Widget content factories use `creates(WidgetModel)` to own their model. +- [ ] Widget models use `@lookup(() => DashViewModel)` and `persistWith: {dashViewModel}` in `onLinked()`. +- [ ] Widget-specific persistable state uses `markPersist('propertyName')`. + +## Persistence Conventions + +- [ ] Use `DashViewProvider` for widget-internal state (via `dashViewModel`). +- [ ] Use `ViewManagerModel` for named dashboard layouts. +- [ ] Never mix persistence providers — one `persistWith` per model. +- [ ] Debounce writes (framework default 250ms is fine). + +## Data Fetching + +- [ ] Use `XH.fetchJson()` / `XH.postJson()` for server calls. +- [ ] Pass `loadSpec` to fetch calls for stale-load protection. +- [ ] Wrap observable mutations in `runInAction()` when outside `@action` methods. +- [ ] Handle errors with `XH.handleException(e)` in load methods. + +## Naming + +- [ ] Models: `PascalCase` ending in `Model` (e.g., `CityChooserModel`). +- [ ] Components: `camelCase` factory function (e.g., `cityChooserWidget`). +- [ ] Files: `PascalCase` matching primary export (e.g., `CityChooserWidget.ts`). +- [ ] CSS classes: `kebab-case` with BEM-like nesting (e.g., `weather-v2-current-conditions__detail`). +- [ ] Prefix all V2 CSS classes with `weather-v2-` to avoid collisions. diff --git a/client-app/src/examples/weatherv2/planning/PLAN.md b/client-app/src/examples/weatherv2/planning/PLAN.md new file mode 100644 index 000000000..2b834fd9f --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/PLAN.md @@ -0,0 +1,550 @@ +# Weather Dashboard V2 — Master Implementation Plan + +## Architecture Overview + +V2 is a new Hoist example app at `client-app/src/examples/weatherv2/` that demonstrates: +1. **Dashboard-as-DSL:** Hoist's native persisted state as a machine-readable dashboard spec. +2. **Inter-widget wiring:** Typed, declarative bindings between widget instances. +3. **LLM-driven generation:** Natural language → validated JSON spec → live dashboard. + +The app is built alongside V1 (no modifications to V1). Server-side weather endpoints are shared. + +### Key Architecture Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Spec format | Hoist native `DashCanvasModel` persisted state | Not a new format — extends existing Hoist persistence | +| Wiring storage | `bindings` key in each widget's `viewState` | Flows through existing persistence pipeline unchanged | +| Widget metadata | Static `meta` property on widget model classes | Simple, explicit, co-located, auto-generation-ready | +| Data access | Shared `WeatherDataModel` with per-city caching | Avoids duplicate API calls across widgets | +| LLM integration | Thin Grails proxy + client-side prompt logic | CORS requires server proxy; prompt iteration stays client-side | +| LLM edit protocol | Full-spec replacement (not JSON Patch) | More reliable for LLM output; validates as a unit | + +--- + +## Phase 1: V2 Scaffolding + +**Goal:** New app shell that loads, renders, and persists — the foundation everything else builds on. + +### 1.1 Create directory structure + +``` +client-app/src/examples/weatherv2/ +├── AppModel.ts — Root app model (creates ViewManagerModel + V2DashModel) +├── AppComponent.ts — App shell with appBar + dashCanvas +├── Types.ts — Weather data types (copy from V1, extend) +├── Icons.ts — Icon definitions (copy from V1) +├── WeatherV2.scss — V2-specific styles +├── dash/ +│ ├── WeatherV2DashModel.ts — Central model: owns DashCanvasModel + WiringModel +│ ├── WiringModel.ts — Runtime wiring coordinator +│ ├── WeatherWidgetModel.ts — Base class for all V2 widget models +│ ├── WidgetRegistry.ts — Widget type registry + schema access +│ └── validation.ts — Spec validation pipeline +└── widgets/ + └── (widget files — Phase 3+) +``` + +### 1.2 Entry point and registration + +- Create `client-app/src/apps/weatherv2.ts` — calls `XH.renderApp()` with V2's `AppModel` and `AppComponent`. +- Register in `ExamplesTabModel.ts` as a new entry alongside V1. + +### 1.3 App shell + +- `AppModel.ts`: Creates `ViewManagerModel` (type: `'weatherDashboardV2'`) and `WeatherV2DashModel`. +- `AppComponent.ts`: `panel` with `appBar` (city selector dropdown for backward compat during development) + `dashCanvas`. +- `WeatherV2DashModel.ts`: Owns `@managed dashCanvasModel: DashCanvasModel` with empty `viewSpecs` initially, `persistWith: {viewManagerModel}`. + +### 1.4 Smoke test + +- App loads at `http://localhost:3000/weatherv2`. +- Empty dashboard canvas renders. +- View manager controls appear in app bar. +- No errors in console. + +**Dependencies:** None. +**Estimated scope:** Small — mostly scaffolding and copy-from-V1. + +--- + +## Phase 2: Wiring Infrastructure + +**Goal:** The `WiringModel`, `WeatherWidgetModel` base class, and `WidgetRegistry` are functional. Widget models can publish outputs and resolve inputs. No widgets yet — just the plumbing. + +### 2.1 WiringModel + +Implement `WiringModel` as described in WIRING-DESIGN.md: +- Observable outputs map: `widgetId → {outputName → value}`. +- `publishOutput(widgetId, outputName, value)` — `@action` that updates the map. +- `resolveBinding(binding: BindingSpec)` — reads from the outputs map. +- `removeWidget(widgetId)` — cleans up on widget removal. + +### 2.2 WeatherWidgetModel base class + +Abstract base class for all V2 widget models: +- `@lookup(() => DashViewModel) viewModel` — links to parent DashCanvasViewModel. +- `resolveInput(inputName)` — reads binding from `viewModel.viewState.bindings`, resolves via `WiringModel`. +- `publishOutput(name, value)` — delegates to `WiringModel`. +- Standard `onLinked()` sets up `persistWith: {dashViewModel: this.viewModel}`. +- Access to `WiringModel` via `AppModel.instance.weatherV2DashModel.wiringModel`. + +### 2.3 WidgetRegistry + +- Singleton `WidgetRegistry` with `register(meta)`, `get(id)`, `getAll()`. +- `generateLLMPrompt()` — produces structured text description of all widgets. +- `generateDashboardSchema()` — produces JSON Schema (or returns the static one). + +### 2.4 TypeScript types + +Define in `dash/types.ts`: +- `WidgetMeta`, `InputDef`, `OutputDef`, `ConfigPropertyDef` +- `BindingSpec` union type +- `DashSpec` (the full dashboard spec shape) +- `ValidationResult`, `ValidationMessage` + +### 2.5 Unit validation + +Basic validation pipeline (full pipeline in Phase 5): +- Structural: check `viewSpecId` against registry. +- Referential: check `fromWidget` references resolve. +- Cycle detection: topological sort of binding graph. + +**Dependencies:** Phase 1. +**Estimated scope:** Medium — core infrastructure, no UI. + +--- + +## Phase 3: Initial Widget Set + +**Goal:** 5 core widgets — enough to demo the wiring story. One input widget (City Chooser) and four display widgets. + +### 3.1 WeatherDataModel + +Shared data provider that caches weather API responses per city: +- `ensureDataLoaded(city: string): Promise` — fetches if not cached. +- `getWeatherData(city: string): WeatherData | null` — synchronous read from cache. +- Observable cache so widgets react when data arrives. +- Owned by `WeatherV2DashModel`, accessed via `AppModel.instance`. + +Normalized data types: +```typescript +interface WeatherData { + city: string; + current: NormalizedCurrent; + forecast: NormalizedForecastEntry[]; + fetchedAt: number; +} +``` + +### 3.2 City Chooser widget + +- `CityChooserModel extends WeatherWidgetModel` with `@bindable @persist selectedCity`. +- Publishes `selectedCity` output on change. +- Component: Hoist `select` input with `enableFilter`. +- Static `meta` declares output, config (selectedCity, cities, enableSearch). + +### 3.3 Current Conditions widget + +- `CurrentConditionsModel extends WeatherWidgetModel`. +- Resolves `city` input, loads weather data from `WeatherDataModel`. +- Renders temperature gauge (Highcharts solid gauge) + conditions details. +- Config: `showFeelsLike`, `showHumidity`, `showWind`, `displayMode`. +- Adapted from V1's `CurrentConditionsWidget` but using wired inputs instead of parent model. + +### 3.4 Forecast Chart widget + +- `ForecastChartModel extends WeatherWidgetModel`. +- Resolves `city` and `units` inputs, loads forecast data. +- Configurable series (`temp`, `feelsLike`, `humidity`, `pressure`) and chart type (`line`, `area`, `column`). +- Adapted from V1's `TempForecastWidget` with expanded series support. + +### 3.5 Precipitation Chart widget + +- `PrecipChartModel extends WeatherWidgetModel`. +- Resolves `city` input. +- Dual-axis chart: probability (%) and volume (mm). +- Config: `metric` (probability/volume/both), `showThresholds`. +- Adapted from V1's `PrecipForecastWidget`. + +### 3.6 5-Day Summary Grid widget + +- `SummaryGridModel extends WeatherWidgetModel`. +- Resolves `city` and `units` inputs. +- Grid with daily high/low, conditions, humidity, wind. +- Config: `visibleColumns`. +- Adapted from V1's `ConditionsSummaryWidget`. + +### 3.7 Wire up DashCanvasModel viewSpecs + +Register all 5 widgets as viewSpecs on `DashCanvasModel`. Set up `initialState` with a default layout: city chooser top-left, display widgets filling the rest. + +### 3.8 End-to-end test + +- App loads with default dashboard. +- City Chooser appears. Selecting a new city updates all display widgets. +- Dashboard layout is drag-resizable. +- Layout persists across page reload. +- View Manager save/load works. + +**Dependencies:** Phase 2. +**Estimated scope:** Large — 5 widget implementations + data model. + +--- + +## Phase 4: Remaining Widgets + Polish + +**Goal:** Complete the widget set (units toggle, wind chart, markdown, inspector) and refine the initial widgets. + +### 4.1 Units Toggle widget + +- `UnitsToggleModel extends WeatherWidgetModel`. +- `@bindable @persist units: 'imperial' | 'metric'`. +- Publishes `units` output. +- Component: Hoist `buttonGroupInput` or `switchInput`. + +### 4.2 Wind Chart widget + +- `WindChartModel extends WeatherWidgetModel`. +- Resolves `city` and `units` inputs. +- Chart: wind speed + optional gusts. +- Config: `showGusts`, `chartType`. + +### 4.3 Markdown Content widget + +- `MarkdownContentModel extends WeatherWidgetModel`. +- `@bindable @persist content: string`. +- Renders via Hoist's markdown rendering (or a simple `div` with `dangerouslySetInnerHTML` from a markdown library). +- No data inputs — pure config-driven. + +### 4.4 Dashboard Inspector widget + +- `DashInspectorModel extends WeatherWidgetModel`. +- Reads from `WiringModel` outputs and `DashCanvasModel` viewModels. +- Renders a tree/list of all widgets with their bindings and current values. +- Updates reactively. + +### 4.5 Update viewSpecs and default layout + +Add all new widgets to `DashCanvasModel.viewSpecs`. Update `initialState` with a comprehensive default dashboard. + +### 4.6 Widget refinements + +- Ensure all widgets handle missing data gracefully (loading mask, placeholder). +- Ensure `units` input works in all applicable widgets (temperature conversion, wind speed). +- Consistent styling across all widgets. + +**Dependencies:** Phase 3. +**Estimated scope:** Medium — 4 additional widgets, incremental. + +--- + +## Phase 5: Spec Validation + JSON Harness + +**Goal:** Full validation pipeline and the JSON editor UI. A human can manually edit dashboard specs. + +### 5.1 Full validation pipeline + +Implement the three-stage validation from DSL-SPEC.md: +1. **Structural:** JSON Schema validation using `ajv` library. +2. **Semantic:** Config property type checking, enum validation, required fields. +3. **Referential:** Binding reference validation, type compatibility, cycle detection. + +Output: `ValidationResult` with errors and warnings, each with JSON path and message. + +### 5.2 JSON Schema generation + +The `WidgetRegistry` produces a complete JSON Schema for the dashboard spec, including per-widget-type `state` schemas derived from `WidgetMeta.config`. + +### 5.3 JSON Harness UI + +New component within the V2 app — a split-pane view: +- **Left panel:** JSON editor with syntax highlighting. Use a code editor component (CodeMirror or Monaco via a lightweight wrapper — evaluate what's simplest to integrate). +- **Right panel:** Live dashboard canvas. +- **Controls:** Apply button, validation status indicator, "Load Example" dropdown, "Copy Spec" button. + +If a standalone code editor is too complex to integrate, a `textarea` with monospace font and basic formatting is acceptable for V1. The key is the validate → apply → hydrate flow. + +### 5.4 Example specs + +3-5 curated example specs accessible from the "Load Example" dropdown: +1. "Basic Weather" — single city, conditions + forecast. +2. "Full Dashboard" — all widget types, single city. +3. "City Comparison" — two cities side by side. +4. "Minimal" — just a city chooser and one chart. +5. "Annotated" — markdown header + display widgets + inspector. + +Store as JSON files or inline constants. + +### 5.5 Spec application flow + +``` +User clicks "Apply" (or auto-apply on valid change) + → Parse JSON + → Run migration (version check) + → Run validation pipeline + → If errors: show errors in UI, keep current dashboard + → If valid: call DashCanvasModel.setPersistableState({state: spec.state}) + → Dashboard hydrates with new widgets +``` + +### 5.6 Spec export + +"Copy Spec" button reads `DashCanvasModel.getPersistableState()` and copies formatted JSON to clipboard. + +**Dependencies:** Phase 4 (full widget set for meaningful examples). +**Estimated scope:** Medium-Large — validation engine + editor UI. + +--- + +## Phase 6: LLM Integration + +**Goal:** End-to-end LLM pipeline — user types a request, dashboard updates. + +### 6.1 Server-side proxy + +- `LlmController.groovy` — single `generate` endpoint, `@AccessAll`. +- `LlmService.groovy` — calls Anthropic Messages API via `JSONClient`. +- Hoist Config entries: `llmApiKey` (pwd), `llmProvider`, `llmModel`, `llmMaxTokens`. +- Error handling: API key not configured → helpful error message. +- Basic rate limiting: per-user counter, 20 req/hour. + +### 6.2 Client-side LLM service + +`LlmChatService` utility: +- `generateAsync(systemPrompt, messages, config)` → calls `/llm/generate` endpoint. +- `buildSystemPrompt(currentSpec?)` → assembles prompt from widget schemas + current spec. +- `parseSpecFromResponse(response)` → extracts JSON spec from LLM text response. + +### 6.3 System prompt + +Assemble from: +- Dashboard spec format description. +- Grid system rules (12 columns, layout constraints). +- Full widget catalog (generated from `WidgetRegistry.generateLLMPrompt()`). +- Wiring rules (binding format, ID assignment). +- Current dashboard spec (if editing). +- Instructions: output only JSON, preserve unmentioned widgets, use sensible defaults. + +### 6.4 Chat harness UI + +Embedded chat panel in the V2 app: +- **Layout:** Chat panel on the left or bottom, dashboard on the right/top. Toggle visibility. +- **Chat display:** Message history (user + assistant). +- **Input:** Text field + send button. +- **Flow:** + 1. User types request. + 2. Client builds system prompt with current spec + widget schemas. + 3. Client sends to `/llm/generate`. + 4. Response received → extract JSON spec. + 5. Validate spec. + 6. If valid: hydrate dashboard, show success in chat. + 7. If invalid: show errors in chat, optionally send repair prompt. + +### 6.5 Error recovery + +- If LLM response isn't valid JSON: show "couldn't parse response" in chat with the raw text. +- If JSON parses but fails validation: show validation errors in chat. Optionally auto-retry with: "Your response had errors: [list]. Please fix and regenerate." +- Max 2 retries before giving up and showing errors to user. + +### 6.6 Streaming (stretch) + +If time permits, stream the LLM response for better perceived latency. Show partial response in chat, parse JSON on completion. + +**Dependencies:** Phase 5 (validation pipeline), Grails server running. +**Estimated scope:** Large — server + client + prompt engineering. + +--- + +## Phase 7: Polish & Demo Prep + +**Goal:** Demo-ready quality. Everything works end-to-end, looks polished, handles edge cases. + +### 7.1 Default dashboard experience + +- On first load (no persisted state), show a compelling default dashboard. +- Default includes: city chooser, units toggle, current conditions, forecast chart, precipitation chart, summary grid. +- Well-laid-out, no gaps, good proportions. + +### 7.2 Error states and loading + +- All widgets show Hoist loading masks during data fetch. +- Graceful handling when weather API returns errors (error message in widget, not app crash). +- Graceful handling when LLM API is unavailable (clear error in chat, JSON harness still works). +- Empty-state messages when no data available. + +### 7.3 Styling and theming + +- Works in both light and dark Hoist themes. +- Consistent color palette across charts. +- Clean, professional look suitable for customer demos. +- Responsive: widgets look good at various sizes (DashCanvas handles this, but verify). + +### 7.4 Curated example specs + +Finalize the 5 example specs. Ensure each one: +- Validates successfully. +- Hydrates into a well-laid-out dashboard. +- Demonstrates a distinct V2 capability. + +### 7.5 Demo scripts + +Walk through the 5 demo scripts from DEMO-SCRIPTS.md. Verify each prompt produces the expected result with the current LLM provider and system prompt. + +### 7.6 Changelog entry + +Add a `CHANGELOG.md` entry for V2 under the current SNAPSHOT version. + +### 7.7 Mock data mode (stretch) + +If time permits, implement the deterministic mock data mode described in RISKS.md R7. + +**Dependencies:** Phase 6. +**Estimated scope:** Medium — mostly refinement and testing. + +--- + +## Phase Dependency Graph + +``` +Phase 1: Scaffolding + ↓ +Phase 2: Wiring Infrastructure + ↓ +Phase 3: Initial Widgets (5) + ↓ +Phase 4: Remaining Widgets (4) + Polish + ↓ +Phase 5: Validation + JSON Harness + ↓ +Phase 6: LLM Integration + ↓ +Phase 7: Polish & Demo Prep +``` + +Phases are strictly sequential — each builds on the prior. Within a phase, sub-tasks can often be parallelized (e.g., multiple widgets in Phase 3). + +## Data Architecture + +### WeatherDataModel + +Central data cache, owned by `WeatherV2DashModel`: + +```typescript +class WeatherDataModel extends HoistModel { + // Observable map: city → WeatherData + @observable.ref private _cache = new Map(); + + /** Get cached data for a city (synchronous, may return null). */ + getData(city: string): WeatherData | null { + return this._cache.get(city) ?? null; + } + + /** Ensure data is loaded for a city. Fetches if not cached or stale. */ + async ensureDataAsync(city: string): Promise { + const cached = this._cache.get(city); + if (cached && !this.isStale(cached)) return cached; + + const [current, forecast] = await Promise.all([ + XH.fetchJson({url: 'weather/current', params: {city}}), + XH.fetchJson({url: 'weather/forecast', params: {city}}) + ]); + + const data = this.normalize(city, current, forecast); + runInAction(() => { + this._cache = new Map(this._cache).set(city, data); + }); + return data; + } + + private isStale(data: WeatherData): boolean { + return Date.now() - data.fetchedAt > 5 * 60 * 1000; // 5 min client-side staleness + } + + private normalize(city, current, forecast): WeatherData { + // Transform raw API responses into NormalizedCurrent + NormalizedForecastEntry[] + } +} +``` + +### Normalized Data Types + +```typescript +interface WeatherData { + city: string; + current: NormalizedCurrent; + forecast: NormalizedForecastEntry[]; + fetchedAt: number; +} + +interface NormalizedCurrent { + temp: number; // Fahrenheit (raw from API, convert to metric in widget) + feelsLike: number; + humidity: number; // Percentage + pressure: number; // hPa + windSpeed: number; // mph + windGust?: number; + conditions: string; // "Clear", "Rain", etc. + description: string; // "light rain", "clear sky", etc. + iconCode: string; // OpenWeatherMap icon code +} + +interface NormalizedForecastEntry { + dt: number; // Unix timestamp (ms) + temp: number; + feelsLike: number; + tempMin: number; + tempMax: number; + humidity: number; + pressure: number; + windSpeed: number; + windGust?: number; + precipProbability: number; // 0-100 + precipVolume: number; // mm per 3h + conditions: string; + description: string; + iconCode: string; +} +``` + +This normalized contract: +- Uses consistent naming (camelCase, not snake_case from API). +- Keeps raw units from the API (imperial — the API returns imperial when requested). +- Widgets handle unit conversion display-side based on their `units` input. +- Is domain-independent enough that swapping weather for financial data would mean changing the interface, not the widget architecture. + +## Persistence Architecture + +### What's Persisted + +| What | Where | How | +|------|-------|-----| +| Dashboard layout + widget config + wiring | ViewManager (JsonBlob, server) | `DashCanvasModel.persistWith = {viewManagerModel}` | +| Widget-specific state (series selection, etc.) | Nested in DashCanvas state | `markPersist()` via `DashViewProvider` | +| Last selected city (on each CityChooser) | Nested in DashCanvas state | `markPersist('selectedCity')` | +| User's preferred units | Nested in DashCanvas state | `markPersist('units')` | + +### Persistence Flow + +``` +User changes widget config + → @persist property updates + → DashViewProvider writes to DashViewModel.viewState + → DashCanvasModel's state computed changes + → PersistenceProvider (ViewManagerProvider) detects change + → Debounced write to ViewManagerModel + → ViewManagerModel persists to JsonBlob (server) +``` + +### Named Views + +Users can save named dashboard layouts via ViewManager: +- "My Dashboard" — personal layout. +- "City Comparison" — shared layout. +- "Default" — the initial built-in layout. + +This is identical to V1's approach. The V2 innovation is that the saved state includes wiring (bindings), so a shared view preserves the full widget composition. + +### Spec ↔ Persisted State Relationship + +The "DSL spec" IS the persisted state. `DashCanvasModel.getPersistableState()` returns exactly the JSON that the JSON harness displays. `setPersistableState()` accepts exactly the JSON that the LLM generates. There's no translation layer — the spec format and the persistence format are one and the same. diff --git a/client-app/src/examples/weatherv2/planning/PROGRESS.md b/client-app/src/examples/weatherv2/planning/PROGRESS.md new file mode 100644 index 000000000..db462f22f --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/PROGRESS.md @@ -0,0 +1,250 @@ +# Progress Log — Weather Dashboard V2 + +## 2026-02-27 + +### Planning Phase Complete + +All 12 planning documents produced: +- **HOIST-CONVENTIONS.md** — House style checklist for V2 implementation. +- **WIRING-DESIGN.md** — Inter-widget IO model: MobX-based publish/resolve through WiringModel. +- **WIDGET-SCHEMA.md** — WidgetMeta interface design with static `meta` per widget class. +- **WIDGET-CATALOG.md** — 9 widgets: 2 input, 5 display, 2 utility. Focused on compositional range. +- **DSL-SPEC.md** — Dashboard spec = Hoist native persisted state. JSON Schema + 3-stage validation. +- **DEPLOYMENT-MEMO.md** — Thin Grails proxy (CORS requirement), Anthropic Claude primary provider. +- **RISKS.md** — 10 risks identified with mitigations. +- **DEMO-SCRIPTS.md** — 5 customer wow scenarios with exact prompts. +- **PLAN.md** — 7-phase master plan with full task breakdown. +- **ROADMAP.md** — Phase overview with key decision points. +- **TASKS.md** — Structured checklist (~55 tasks across 7 phases). + +### Key Design Decisions Made + +1. **Spec format = Hoist native state.** No new DSL. The `DashCanvasModel.getPersistableState()` output IS the spec. Wiring added via `bindings` key in widget viewState. +2. **WiringModel as MobX coordinator.** Observable output map. Widgets publish/resolve through it. Changes propagate via standard MobX reactivity. +3. **WeatherWidgetModel base class.** All widgets extend it. Provides `resolveInput()`, `publishOutput()`, standard `onLinked()` persistence setup. +4. **Shared WeatherDataModel.** Per-city caching. Widgets don't fetch directly — they request data from the shared model. Avoids duplicate API calls. +5. **Full-spec replacement for LLM edits.** Not JSON Patch. Simpler, more reliable, validates as a unit. +6. **Grails proxy for LLM.** CORS prevents direct browser→Anthropic calls. Thin proxy adds API key server-side. + +### Execution — Phase 1+2: Scaffolding + Wiring (Complete) + +Created V2 app shell: +- `apps/weatherv2.ts` entry point +- `AppModel.ts`, `AppComponent.ts` — app bar + dashCanvas +- `WeatherV2DashModel.ts` — owns DashCanvasModel + WiringModel + WeatherDataModel +- `WiringModel.ts` — observable pub/sub for widget outputs +- `WeatherWidgetModel.ts` — base class with resolveInput/publishOutput +- `WidgetRegistry.ts` — singleton registry with generateLLMPrompt +- `WeatherDataModel.ts` — per-city weather data caching +- `dash/types.ts` — WidgetMeta, BindingSpec, DashSpec, ValidationResult +- `Types.ts` — WeatherData, NormalizedCurrent, NormalizedForecastEntry +- `Icons.ts`, `WeatherV2.scss` +- Registered in ExamplesTabModel.ts + +### Execution — Phase 3: Initial Widget Set (Complete) + +5 core widgets: +- **CityChooserWidget** — select input, publishes `selectedCity`, 25 world cities +- **CurrentConditionsWidget** — solid gauge + icon + details, consumes city+units +- **ForecastChartWidget** — multi-series configurable chart (temp/feelsLike/humidity/pressure) +- **PrecipChartWidget** — dual-axis probability + volume chart +- **SummaryGridWidget** — daily overview grid with groupBy aggregation + +Plus `unitUtils.ts` for temperature/wind unit conversion. + +### Execution — Phase 4: Remaining Widgets (Complete) + +4 additional widgets: +- **UnitsToggleWidget** — buttonGroupInput, publishes `units` output +- **WindChartWidget** — wind speed + gusts chart, consumes city+units +- **MarkdownContentWidget** — simple markdown-to-HTML renderer +- **DashInspectorWidget** — debug grid showing live widget instances, bindings, outputs + +All 9 widgets registered in DashCanvasModel viewSpecs. Initial state includes units toggle wired to all unit-aware display widgets. + +### Execution — Phase 5: Validation + JSON Harness (Complete) + +- **validation.ts** — Three-stage pipeline: structural (unknown types, missing fields, layout bounds), semantic (config types, enums, binding inputs), referential (dangling refs, output existence, cycle detection) +- **exampleSpecs.ts** — 4 curated specs: Minimal, Full Dashboard, City Comparison, Annotated +- **JsonHarnessModel.ts** — Parse → migrate → validate → apply flow + sync/export +- **JsonHarnessPanel.ts** — jsonInput editor + validation display + Load Example dropdown + Apply/Validate/Sync/Copy controls +- App bar JSON button toggles the harness as a side panel + +### Execution — Phase 6: LLM Integration (Complete) + +Server-side: +- **LlmController.groovy** — POST `/llm/generate` endpoint +- **LlmService.groovy** — Anthropic Messages API proxy via JSONClient, per-user rate limiting, config-driven (llmApiKey, llmModel, llmMaxTokens, llmRateLimit) + +Client-side: +- **LlmChatService.ts** — System prompt builder (widget schemas + spec format + wiring/layout rules + current spec), response parser (JSON extraction from code fences) +- **ChatHarnessModel.ts** — Conversation history, LLM API calls, auto-applies validated specs +- **ChatHarnessPanel.ts** — Chat UI with message bubbles, error display, text input + +App bar Chat button toggles the harness alongside JSON harness. + +## 2026-02-28 + +### Execution — Phase 7: Polish (Complete) + +Final review pass: +- All 9 widgets compile cleanly with full TypeScript type checking +- ESLint and stylelint pass with zero errors +- All files formatted by Prettier via pre-commit hooks +- Default dashboard layout includes city chooser, units toggle, conditions, forecast, wind, precip, and summary grid — all wired together +- 4 example specs validate successfully + +### Commit History + +1. `f6a5ee78` — Planning artifacts (12 docs) +2. `5a87fa56` — Phase 1+2 scaffolding + wiring infrastructure +3. `760d56c7` — Phase 3 initial widget set (5 widgets) +4. `9188172c` — Phase 4 remaining widgets (4 widgets) +5. `7e18e8b3` — Phase 5 validation pipeline + JSON harness +6. `12de0c6d` — Phase 6 LLM integration (server + client + chat harness) + +### What's Working + +- Full 9-widget catalog with typed inputs/outputs/config +- Inter-widget wiring via MobX-reactive WiringModel +- Per-city weather data caching via WeatherDataModel +- Spec validation with detailed error messages + JSON paths +- JSON harness: edit → validate → apply → dashboard updates live +- LLM chat harness: natural language → system prompt → spec → validation → hydration +- 4 curated example specs loadable from dropdown + +### LLM Chat Service Promoted to HoistService + +LlmChatService refactored from a plain class to a proper HoistService, installed via `XH.installServicesAsync()` in AppModel. Accessible as `XH.llmChatService` throughout the app. + +### UI Polish + +- JSON harness: improved UX with persisted panel state and added testIds +- Chat/JSON panels use `PanelModel` with right-side resizable layout + +### Runtime Testing Results + +All runtime features confirmed working with live Grails server: +- Weather API calls via OpenWeatherMap — data caching works correctly +- LLM proxy via Anthropic API — full round-trip conversation works +- DashCanvas drag/resize works +- Widget instance IDs in bindings match DashCanvasModel's ID generation +- ViewManager persistence across page reloads works + +### Chat UX: Enter-to-Submit + +Added `onKeyDown` handler to ChatHarnessPanel's textArea. Enter now submits the message; Shift+Enter inserts a newline. Matches standard chat UX conventions. + +### Markdown Widget Rendering Fix + +The markdown widget was rendering header content inline with body text because Hoist's `box` component applies `display: flex` via inline styles, causing react-markdown's block-level elements (h1, p) to lay out horizontally. Fixed by replacing `box` with a plain `div` from `@xh/hoist/cmp/layout` — the CSS `.weather-v2-markdown` class provides the necessary `flex: 1`, `padding`, and `overflow: auto` for the widget to fill its card correctly. + +### Thorough LLM Chat Testing (12 API + 3 UI tests) + +Systematic testing of the LLM chat functionality across multiple scenarios: + +**Results (all passing):** +- Fresh dashboard builds from natural language (correct widget selection, bindings, layout) +- Iterative refinement (add/remove/modify widgets while preserving existing ones) +- Multi-instance wiring (dual city choosers with correct `cityChooser`/`cityChooser_2` instance IDs) +- Capability self-description (accurate listing of all 9 widgets with categories and wiring explanation) +- Off-topic/impossible requests handled gracefully (poems, stock prices, gibberish all redirected) +- Prompt injection rejected cleanly +- Const bindings used correctly for hard-wired city inputs +- Full end-to-end UI pipeline: LLM → JSON parse → validation → hydration → live dashboard + +**System prompt quality:** The current prompt produces consistently valid specs with correct bindings, sensible layouts, and appropriate widget selection. No critical issues found. + +### Auto-Generated Widget Titles + +Implemented reactive auto-titles that show widget context (particularly the active city) in widget headers: + +**Architecture:** +- `BaseWeatherWidgetModel.getAutoTitle()` — virtual method returning `null` by default +- `BaseWeatherWidgetModel.onLinked()` — reaction tracks `getAutoTitle()` and pushes to `viewModel.title` +- Display widgets override: `"Current Conditions — Tokyo"`, `"Forecast — Chicago"`, etc. +- Titles update reactively when the city chooser selection changes + +**Widget-specific behavior:** +- **Display widgets** (CurrentConditions, ForecastChart, PrecipChart, WindChart, SummaryGrid): Auto-generate `""` titles from their bound city input +- **MarkdownContent**: Exposes a `title` config in its state (`state.title`) — LLM/user sets it explicitly since content is free-form +- **Input widgets** (CityChooser, UnitsToggle) and **DashInspector**: Use default viewSpec titles + +**User renaming disabled:** Set `allowRename: false` on all 9 viewSpecs in `WeatherV2DashModel` to prevent conflicts between user-set titles and auto-generated ones. + +**System prompt updated:** Added guidance telling the LLM not to set top-level `title` for auto-titled display widgets (it would be overwritten) and to use `state.title` for markdown widgets. + +**Example specs updated:** Removed top-level `title` from display widgets in comparison spec; moved markdown title into `state.title` in annotated spec. + +### What's Working (Updated) + +- Full 9-widget catalog with typed inputs/outputs/config +- Inter-widget wiring via MobX-reactive WiringModel +- Per-city weather data caching via WeatherDataService +- Spec validation with detailed error messages + JSON paths +- JSON harness: edit → validate → apply → dashboard updates live +- LLM chat harness: natural language → system prompt → spec → validation → hydration +- Enter-to-submit in chat with Shift+Enter for newlines +- Auto-generated widget titles with reactive city context +- Markdown rendering with proper block layout +- 4 curated example specs loadable from dropdown +- Widget renaming disabled to prevent title conflicts + +### LLM Tool Use (Function Calling) + +Added Anthropic native tool use API to give the LLM callable tools for app operations beyond dashboard spec generation. + +**Architecture:** +- Tools execute **client-side** since they manipulate UI state (ViewManager, theme, panels) +- Server is a pass-through — forwards the `tools` array to the Anthropic API +- Multi-turn loop: LLM responds with `tool_use` blocks → client executes → sends `tool_result` → LLM responds with final text (max 5 iterations) + +**6 tools implemented:** +| Tool | Action | +|------|--------| +| `save_dashboard_as_view` | Save current dashboard as a named view | +| `switch_to_view` | Switch to an existing saved view by name | +| `reset_dashboard` | Reset dashboard to saved state | +| `toggle_theme` | Toggle light/dark theme | +| `open_widget_chooser` | Open widget chooser panel | +| `show_json_spec` | Open JSON spec editor | + +**Files changed:** +- **Server:** `LlmService.groovy` + `LlmController.groovy` — accept optional `tools` param, increased default max tokens to 8192 +- **Client (new):** `svc/LlmToolService.ts` — HoistService with tool definitions + execution +- **Client (modified):** `svc/LlmChatService.ts` — returns full content block array, tool guidance in system prompt +- **Client (modified):** `harness/ChatHarnessModel.ts` — tool use loop, split API messages from display messages, ToolCallDisplay type +- **Client (modified):** `harness/ChatHarnessPanel.ts` — tool calls rendered as collapsible `
` elements, tool-use suggestion in empty state +- **Client (modified):** `WeatherV2.scss` — `.weather-v2-tool-call` styles +- **Client (modified):** `AppModel.ts` + `Bootstrap.ts` — LlmToolService registration + type augmentation + +## 2026-03-01 + +### Input Binding Lifecycle — Culling, Simplified Sources, Unbound State + +Three related changes to how widget input bindings are managed: + +**1. Binding culling on widget removal (`WeatherV2DashModel.ts`)** +- Added MobX reaction tracking `dashCanvasModel.viewModels` IDs +- On widget removal: calls `wiringModel.removeWidget()` AND scans all remaining widgets to delete bindings referencing the removed widget +- On widget addition: seeds declared input defaults into `viewState` (only if neither binding nor manual value already exists) +- Registered before the auto-title reaction so culled viewState settles first + +**2. Simplified `resolveInput` fallback chain (`BaseWeatherWidgetModel.ts`)** +- Removed the meta-default fallback (step 3) +- Chain is now: binding → manual viewState value → `undefined` (unbound) +- Input defaults are seeded into viewState at addition time, so the meta fallback is redundant + +**3. Simplified settings form source model (`WidgetSettingsForm.ts`)** +- Removed `SOURCE_DEFAULT` constant and "Default" dropdown option +- Added `SOURCE_UNBOUND` sentinel for unconfigured inputs +- Three states: Linked (bound to provider), Manual (direct value), Unbound (needs configuration) +- Warning icon and note shown for unbound required inputs +- "Not configured" option only appears when input is currently unbound + +**4. Validation updates (`validation.ts`)** +- `UNBOUND_REQUIRED_INPUT` now checks for missing binding AND missing manual value +- Input names added to `knownKeys` set so manual values don't trigger `UNKNOWN_STATE_KEY` warnings + +**5. Unbound state styling (`WeatherV2.scss`)** +- Warning-colored note and icon styles for unbound inputs in settings form diff --git a/client-app/src/examples/weatherv2/planning/PROMPT.md b/client-app/src/examples/weatherv2/planning/PROMPT.md new file mode 100644 index 000000000..d6cff8c1b --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/PROMPT.md @@ -0,0 +1,390 @@ +# Weather Dashboard V2 (Toolbox Demo) — Hoist-native, DSL-grade, LLM-driven + +## Context + +### What V1 is + +The Toolbox project already has a V1 "Weather Dashboard" example app, entirely AI-generated. It lives at: + +**Client** — `client-app/src/examples/weather/` +- `AppModel.ts` / `AppComponent.ts` — top-level app shell with city selector dropdown in the app bar. +- `WeatherDashModel.ts` — central model: holds `selectedCity`, fetches data, owns a `DashCanvasModel`. +- `Types.ts` — typed response interfaces (`CurrentWeatherResponse`, `ForecastResponse`, `ForecastItem`). +- `Icons.ts`, `Weather.scss` — icons and styles. +- `widgets/` — six widget files, each a model+component pair: + - `CurrentConditionsWidget` — Highcharts solid gauge + details card. + - `TempForecastWidget` — spline chart (temp + feels-like over 5 days). + - `PrecipForecastWidget` — dual-axis column chart (probability + volume). + - `WindForecastWidget` — spline chart (speed + gusts). + - `HumidityPressureWidget` — dual-axis spline chart. + - `ConditionsSummaryWidget` — grid with daily highs/lows, conditions, humidity, wind. + +**Server** — Grails backend: +- `WeatherController.groovy` — two endpoints: `weather/current`, `weather/forecast`. `@AccessAll`. +- `WeatherService.groovy` — calls OpenWeatherMap API (`/data/2.5/weather`, `/data/2.5/forecast`), caches results (10 min current, 30 min forecast). API key from Hoist `Config`. + +**Entry point** — `client-app/src/apps/weather.ts`, registered in `ExamplesTabModel.ts`. + +V1 uses `DashCanvasModel` for a draggable/resizable widget grid and `ViewManagerModel` for save/restore of named layouts. All widgets consume a single `selectedCity` from the app bar — no inter-widget wiring, no user-configurable widget settings, no JSON-level spec editing. + +### What V2 is + +V2 is not about faster data or realtime. It is a demo-quality proof that **Hoist dashboards + persistable state can function as a declarative DSL** that an LLM can generate safely and correctly, producing JSON that hydrates into a real, interactive dashboard at runtime. + +The enterprise-dashboard story: "business user describes what they want; agent produces schema-valid dashboard spec; Hoist renders it; the dashboard remains fully persistable, shareable, and editable." + +### V1/V2 coexistence + +V2 is built **alongside** V1, not on top of it. V1 stays unchanged so we can do an A/B comparison: "We started here, then we did this huge upgrade." + +- V2 gets its own directory: `client-app/src/examples/weatherv2/` (or similar), its own app entry point, and its own nav registration in `ExamplesTabModel.ts`. +- **Copy V1 client code as a starting point.** No need to deduplicate widgets or models across V1 and V2 — they are independent example apps. +- **Exception: the weather data surface.** `WeatherService.groovy` and `WeatherController.groovy` should be shared (or extracted to a common location) so both apps hit the same server-side endpoints. If V2 needs richer data (e.g., hourly breakdowns, derived metrics), extend the shared service — V1 can simply ignore the new fields. + +--- + +## Primary objectives (in priority order) + +1. **Hoist fidelity.** The code must read like a real Hoist app written by Hoist maintainers. Conform to established patterns, naming conventions, model/component conventions, state persistence patterns, and style guidelines. Avoid "off-reservation" architecture. + + Before writing any code, use the **hoist-react MCP server** to study the framework: + - `hoist-search-docs` — keyword search across all framework docs (filterable by category: package, concept, conventions). + - `hoist-search-symbols` / `hoist-get-symbol` / `hoist-get-members` — TypeScript API introspection (classes, interfaces, types, signatures, JSDoc). + - `hoist-list-docs` — browse the full doc catalog. + + The **full hoist-react source** is also checked out locally at `../hoist-react/` and is up-to-date with the version used by this project. When you need to go deeper than the docs — e.g., reading the actual `Persistable` interface, `PersistenceProvider` implementation, or `DashCanvasModel` serialization logic — read the source directly. + + Key docs to read (by doc ID): + - `desktop/cmp/dash` — Dashboard system: DashContainerModel, DashCanvasModel, DashViewSpec, widget lifecycle. **Start here.** + - `persistence` — Hoist's persistence system: PersistenceProvider, backing stores, read/write/clear lifecycle. + - `cmp/viewmanager` — ViewManagerModel: save/load named state bundles, JsonBlob persistence, sharing. + - `core` — Foundation: HoistModel, component/model conventions, @managed, services. + - `conventions` — Coding style: imports, naming, class structure, component patterns, exports. + - `lifecycle-models-and-services` — Model/service lifecycles, load/refresh patterns. + - `cmp/layout` — Flexbox layout containers (relevant to dashboard arrangement). + + Key source files to read directly: + - `../hoist-react/core/persist/Persistable.ts` — the `Persistable` interface and `PersistableState` class. + - `../hoist-react/core/persist/PersistenceProvider.ts` — how persistence reads/writes/binds to models. + - `../hoist-react/desktop/cmp/dash/canvas/DashCanvasModel.ts` — how the dashboard serializes its state graph. + +2. **Dashboard-as-DSL.** The "DSL" is **not** a new format — it is Hoist's native persisted state. Hoist already has deep, nested persistence: `DashCanvasModel` serializes its layout and views via `getPersistableState()`, each view carries nested state from its own model hierarchy, and those models in turn persist their children (grids persist column config, charts persist series selection, etc.). This entire JSON graph is already produced and consumed by real Hoist code. **We use it as-is.** + + The innovation is a **meta-layer** — a machine-readable description of what that persisted state graph *looks like* for each widget type. This is what the LLM needs: not a new spec format, but a schema that says "for a widget of type `forecastChart`, the persisted state has shape `{series: string[], chartType: 'line'|'bar', ...}`" etc. Without this, the LLM can't produce valid state. + + **The ambition** is to derive this meta-layer by introspecting the existing persistence machinery. Models already implement the `Persistable` interface (`getPersistableState()` / `setPersistableState()`). Properties are decorated with `@bindable` and `@managed`. If we can walk the model hierarchy and its persistence annotations, we can auto-generate an accurate schema for each widget type. Study how `PersistableState`, `PersistenceProvider`, and the `persistWith` config work — the hooks for introspection may already exist. + + **The pragmatic starting point** is a manually maintained schema per widget type — each widget declares the shape of its persistable state via a structured object or JSON Schema fragment. This is fine initially and may be the right long-term answer for the subset of state that's meant to be LLM-authored (not all persistable state needs to be exposed). The key is designing the interface cleanly enough that it *could* be auto-generated later. + + Don't let this drive excessive complexity. A hard-coded schema that accurately describes the current widget set's persistable state is a perfectly valid deliverable. But the plan should identify where auto-generation could plug in. + +3. **Inter-widget wiring.** This is the primary technical goal after the DSL itself. Widgets must be composable: some emit values (e.g., selected city), others consume them via explicit bindings. This wiring must be part of persisted state. The widget set exists to *serve* the wiring story — every widget should justify itself by enabling interesting, plausible compositions. Think about multiplicity (one city chooser driving five display widgets), specificity (a units widget narrowing what a chart shows), and small multiples (two instances of the same widget type, each bound to a different city). + +4. **Compelling widget set.** The widget count matters less than the *compositional range*. We don't need a dozen widgets — we need a set where every type enables interesting interrelations with others using the data we have available. Add or refine widgets where they create new wiring possibilities; don't add widgets just to pad the catalog. The planning agent is free to use its imagination here, but should avoid elaborate technology integrations (e.g., deep map APIs) that distract from the core story. Widget development is a **parallel workstream** to the schema/wiring/LLM pipeline — it can proceed incrementally without blocking the primary goals. + +5. **Two harnesses:** + - **JSON harness** — a text editor panel that can view/edit the full dashboard spec JSON, validate it, and hydrate it into a live dashboard. + - **LLM harness** — an embedded chat UI that takes natural-language requests and produces updated dashboard specs iteratively, then applies them. + +6. **End-to-end LLM pipeline (tracer bullet).** The JSON harness alone is valuable — a human can copy specs back and forth between an LLM and the dashboard. But the real win is a deployed, working pipeline: user types a request → server proxies to an LLM provider → response validates and hydrates into a live dashboard. This is what makes the demo a success. Plan for this end-to-end path from the start, even if it's wired up last. + +--- + +## Non-goals / guardrails + +- Do not chase realtime/push messaging as a core theme. Periodic refresh is secondary. +- Do not build a bespoke widget framework unrelated to Hoist. Extend/compose Hoist dashboard conventions. +- Do not allow unbounded code execution via LLM. The LLM outputs JSON specs only, validated against schema. +- Keep scope ambitious but demo-shippable. A coherent, polished V2 beats a sprawling science fair. +- Do not modify V1. It stays as-is for A/B comparison. + +--- + +## Additional resources + +- **Jobsite sample app** — checked out locally at `../jobsite/`. This is a strong reference for widget design and formulation patterns in a real Hoist app. Study its dashboard and component architecture when planning V2 widgets. +- **Local Toolbox instance** — Toolbox is running locally and viewable in Chrome. Use browser automation tools to inspect the running V1 weather app, test changes, and verify behavior. Refresh as needed. +- **hoist-react source** — at `../hoist-react/`, up-to-date. Read source directly when docs aren't enough. +- **hoist-core docs** — at `../hoist-core/docs/`. Consult before planning any Grails server-side work. + +--- + +## Operating mode + +**Work autonomously.** Research, plan, formulate, reformulate, test, and polish a full implementation plan independently. Then proceed to execution. + +- **Checkpoint frequently.** Commit work-in-progress to this branch as often as needed — it's been set up for you. Use commits as save points. +- **Keep a progress log.** Write a `docs/planning/weather-v2/PROGRESS.md` file and update it as you complete tasks, hit decisions, or change direction. This is your running journal. +- **Proceed to execution.** Once the plan is solid, begin implementing. Don't wait for approval — the plan documents are the contract. If you hit a genuine ambiguity that could go badly either way, note it in the progress log and make your best call. +- **This is an AI technology excellence demo in every respect.** The quality of the planning, the code, and the autonomous execution are all part of what we're showing off. Go for it. + +--- + +## Planning agent tasks + +### 1) Read + assimilate Hoist patterns (mandatory first step) + +Use the **hoist-react MCP server** (see objective #1 above for tool names). Read documentation for: +- Dashboard components and widget patterns (`desktop/cmp/dash`) +- State persistence / serialization / hydration (`persistence`) +- Model organization, MobX usage, component conventions (`core`, `conventions`) +- View management (`cmp/viewmanager`) +- Existing example apps in this repo, especially the V1 weather app (see file paths in Context above) + +Identify "house style" expectations: file layout, naming, class patterns, validation approaches, how Hoist thinks about models/views/stores. + +**Deliverable:** a short "Hoist conventions checklist" the implementation will follow (used as a rubric during development). + +--- + +### 2) Document and schematize the existing persistence format + +The DSL is Hoist's native persisted state — not a new format. The task here is to **understand, document, and schematize** what the existing persistence machinery already produces. + +**Start by studying the actual persistence output:** +- `DashCanvasModel` implements `Persistable` and serializes to `PersistableState<{state: DashCanvasItemState[]}>`. Each item in that array represents a widget instance with layout coordinates and a nested `state` object from the widget's own model. +- Widget models (e.g., V1's `TempForecastModel`) may themselves contain `ChartModel`, `GridModel`, etc., each with their own persistable state. +- Read the `persistence` doc via MCP. Read the `Persistable` interface and `PersistenceProvider` source in `../hoist-react/core/persist/`. Examine how V1's `WeatherDashModel` uses `persistWith` today. + +**Produce:** +- A documented description of the persisted state shape for the V2 dashboard — what `DashCanvasModel.getPersistableState()` returns, what each widget's nested state looks like, and how the full JSON graph is structured. +- A formal JSON Schema (or equivalent) for this state, at least for the top-level dashboard structure and the per-widget-type state shapes. +- A semantic validation layer for things schema can't catch (e.g., references to widget IDs that don't exist, cycles in wiring, incompatible types). +- A versioning/migration approach for the persisted state (even if only v1 → v2). This matters for "persisted state as product." + +The goal: a human with a JSON editor, or an LLM with the schema, should be able to author valid persisted state that `DashCanvasModel.setPersistableState()` will accept and hydrate into a working dashboard. + +--- + +### 3) Design the widget schema interface + +Each widget type needs a structured, machine-readable description of its configuration surface — the **widget schema**. This is what the LLM consumes to produce valid specs, and what the JSON harness validates against. + +**What it must describe per widget type:** +- Widget type name (string literal). +- Configurable properties: name, type, constraints (enums, ranges, required vs optional), defaults. +- Input bindings the widget accepts (name, expected type, default-when-unbound). +- Output values the widget emits (name, type). +- Any display modes or layout hints. + +**Design the interface itself** — how does a widget declare this? Options include: +- A static `configSchema` property on the widget model class (a structured object or JSON Schema fragment). +- A TypeScript interface per widget type, with a convention for extracting it. +- A registry that maps widget type names to schema definitions. + +**Starting point:** a manually maintained schema per widget is fine. Each widget declares its own interface as a structured object. This is the V1 deliverable. + +**Stretch goal (plan for, don't over-build):** auto-generation by introspecting Hoist's persistence graph. Hoist models already declare persistable state — dashboards, grids, charts, forms all have serialization boundaries. If the widget schema could be derived from these declarations (or a curated subset), it would stay accurate automatically as widgets evolve. Identify where this could plug in and what Hoist extension points would enable it, but don't let it block the initial implementation. + +**Deliverable:** the widget schema interface design, with at least 2-3 example widget schemas showing the full range (a simple input widget, a complex chart widget, a meta/inspector widget). + +--- + +### 4) Design the widget IO/wiring model + +This is core to the demo. We want a clean, not-too-clever formalism that's easy to understand and easy for an LLM to generate correctly. + +**Required capabilities:** +- Widgets can **emit outputs** with a stable name (e.g., `selectedCity`), a type (e.g., `CityId | string | number | DateRange`), and optional metadata (label, description). +- Widgets can **declare inputs** they accept, with type constraints and default-value behavior when unbound. +- In persisted state, a widget can **bind an input** to: + - Another widget's output: `{fromWidgetId, outputName}` + - A constant literal: `{const: ...}` + - Optionally a "dashboard variable" (if you introduce global vars) + +**Constraints:** +- Validate that bindings refer to existing widget IDs + output names. +- Validate type compatibility. +- Prevent cycles (start with "no cycles"; define explicit support later if needed). +- Runtime resolution via a dependency graph where upstream changes propagate to downstream widgets. Must be MobX-friendly and Hoist-idiomatic. + +Note: V1 has no wiring — `selectedCity` is a single value on `WeatherDashModel` that all widgets read directly. V2 replaces this with the general IO model above. + +**Deliverable:** a crisp "Widget IO Spec" section in the plan, with example JSON fragments. + +--- + +### 5) Widget set: compositional range over raw count + +V1 has six widgets (current conditions, temp forecast, precip forecast, wind, humidity/pressure, 5-day summary). V2's widget set should be driven by **compositional range**, not raw count. + +**Guiding principles:** +- Every widget must justify itself by enabling interesting wiring relationships with other widgets. +- Each widget should have a few clear config knobs, optional display modes, and thoughtful defaults so the LLM can succeed without specifying everything. +- Think about the compositions: one input widget driving many display widgets, small multiples (same widget type bound to different inputs), cascading filters, side-by-side comparisons. +- Avoid elaborate technology integrations (deep map APIs, complex third-party libraries) that distract from the core schema/wiring story. +- Widget development is a **parallel workstream** — it can proceed incrementally without blocking the DSL spec, wiring model, or harness work. + +**Suggested widget types** (use your judgment — add, remove, or recombine): + +*Input/control widgets (emit values that drive other widgets):* +- **City Chooser:** emits `selectedCity`. Config: allowed cities list, default city, search/filter UI, display style. +- **Date Range Chooser:** emits `dateRange`. Config: preset ranges, min/max, granularity. +- **Units / Preferences:** emits `units` (metric|imperial), maybe time format. + +*Display widgets (consume inputs, render data):* +- **Current Conditions card:** config: visible fields, compact vs detailed, icon style. +- **Forecast chart:** config: hours vs days, series selection (temp/precip/wind), chart type. +- **Precipitation detail:** config: accumulation vs probability, time bucket, threshold highlights. +- **Wind widget:** config: gusts vs sustained, direction rose vs numeric. + +*Stretch / high-demo-value widgets:* +- **Markdown content widget:** renders arbitrary markdown via Hoist's `RenderedMarkdown` component (see Toolbox docs examples for usage). Useful as a generic display surface — static content, instructions, annotations. A good building block for compositions. +- **LLM-generated forecast summary:** a widget that calls an LLM to produce a natural-language weather analysis from the current data. This is a **technology stretch goal** — distinct from the dashboard-designer LLM harness. It would make the demo significantly more compelling ("the LLM doesn't just *build* the dashboard, it *fills in* content too"). Fine to stub initially with a template, but plan the architecture for a real LLM call. +- **Debug / Inspector widget:** shows resolved inputs/outputs + wiring graph for the current dashboard. Makes the IO story legible to humans and customers during demos. + +**Deliverable:** a widget catalog (table): type, purpose, inputs, outputs, config keys, default behavior. + +--- + +### 6) JSON harness UI (manual editing → live hydration) + +A page that makes the DSL tangible. + +**Required UX:** +- A JSON editor panel with syntax highlighting, formatting, and validation feedback. +- Schema validation errors shown with paths (e.g., `widgets[3].config.foo`). +- An "Apply" control (or auto-apply with debounce, but only if stable). +- On apply: validate → if valid, hydrate dashboard; if invalid, keep last known-good config and show errors. + +**Additional must-haves:** +- "Load example specs" dropdown — several curated examples demonstrating wiring + layout. +- "Export current spec" button (copy/download). +- "Snapshot" concept: capture current dashboard state/spec as a named sample. + +**Deliverable:** planned UI layout + interaction flow. + +--- + +### 7) LLM harness UI (chat → JSON spec → live dashboard) + +The headline demo: "Describe a dashboard; agent builds it." + +**Phased delivery:** +- **Phase A (unblocked):** The JSON harness (task #6) ships first. With it, a human can act as the relay — copy the current spec into an external LLM, describe changes in natural language, paste the result back, and apply it. This is immediately useful for validating the schema and prompt strategy without any server-side LLM integration. +- **Phase B (requires LLM integration):** The in-app chat harness connects to an LLM backend — either client-side with a local API key or via a Grails proxy (see task #10 for the decision). The user types a request, the LLM responds with a spec/patch, the app validates and applies it. This is the real demo. + +Design the chat UI and prompt protocol now (Phase B), but recognize that Phase A is the unblocked path for early validation. + +**Functional requirements:** +- Embedded chat panel in-app. +- The assistant receives: the **widget schema** (see objective #2 and task #3 — the structured description of all available widget types, their configs, and IO bindings), the current dashboard spec (if iterating), and clear instructions to output only JSON spec (or JSON patch) in a machine-parseable format. The quality of this schema directly determines the LLM's accuracy — it's the most important input to the system prompt. +- The assistant returns: either a full dashboard spec or a patch/diff to apply to the current spec. +- The app: validates output → applies if valid → if invalid, shows errors and optionally asks the assistant to repair. + +**Strong recommendation — incremental edit protocol:** +- Maintain a "current spec." +- Ask the LLM for a patch-like result (JSON Patch, or a simple `{op, path, value}` list). +- Apply patch → validate → commit. +- This reduces failure rate and supports iterative conversation. + +**Safety/robustness:** +- Hard validation gate: invalid specs never hydrate. +- Rate limiting / token limits / timeouts (enforced server-side via the proxy). +- Clear system prompt boundaries: no code execution, no network calls via LLM output. +- Logging: capture prompts + outputs for debugging (sanitized). + +**Deliverable:** proposed chat protocol, prompt strategy, and application flow. + +--- + +### 8) Backend / data model + +The weather data is the vehicle, not the destination. The demo's success hinges on widget composition, state serialization, and LLM integration — not on rich meteorological data. Even simple weather data is sufficient to tell that story and exercise all the interesting technical challenges. + +**Design for data-source independence.** Weather is the first domain, but a likely follow-up is swapping the data layer for financial/marketplace data while keeping the core (persistence, wiring, LLM integration) intact. The widget data contract should be clean enough that this kind of swap is straightforward — widgets consume a typed data interface, not raw API responses. + +**Starting point:** V1's `WeatherService.groovy` calls OpenWeatherMap's free tier (`/data/2.5/weather` and `/data/2.5/forecast`) with caching (10 min current, 30 min forecast). `WeatherController.groovy` exposes `weather/current` and `weather/forecast`. Response types are defined in `Types.ts` on the client. + +**Constraints and guidance:** +- Stay on the **OpenWeatherMap free tier** for now. The free `/forecast` endpoint returns 5-day/3-hour data — enough to populate charts, grids, and condition displays. If research turns up a low-cost paid option that meaningfully improves the demo, flag it, but don't depend on it. +- Derive what you can from the existing data: daily highs/lows, feels-like, precipitation accumulation can all be computed client-side or server-side from the 3-hour forecast intervals. +- A **normalized, typed data contract** that widgets consume (not raw API passthrough) will make the eventual domain swap cleaner. + +**Deterministic mock mode:** consider a stable dataset option so LLM-driven layouts aren't confounded by flaky API responses or changing weather. Useful for demos and testing. + +Remember: changes to `WeatherService` and `WeatherController` affect both V1 and V2. Extend additively — don't break V1's existing endpoints. + +**Deliverable:** a proposed data contract used by widgets (typed), and which endpoints supply it. + +--- + +### 9) Persistence story (Hoist-native) + +This demo only works if it uses Hoist persistence patterns convincingly. + +**Starting point:** V1 uses `DashCanvasModel` with `persistWith: {viewManagerModel}`, backed by localStorage. Read the `persistence` and `cmp/viewmanager` docs via MCP to understand the full machinery before designing V2's approach. + +**Decide:** +- What is persisted? The entire spec? Spec + derived runtime state? Per-user vs shared named dashboards? +- How does the DSL spec map to Hoist's existing persistence model? Is the spec *the* persisted state, or a layer above it? +- Migration story: how we evolve spec versions safely. +- "View manager / save views" angle: V1 already uses ViewManagerModel — does V2 deepen this, or does the DSL spec replace it? + +**Deliverable:** persistence architecture section with concrete state boundaries. + +--- + +### 10) LLM provider integration + deployment (customer-trial-ready) + +This is the tracer bullet that makes the demo real. Research and recommend an approach with clear rationale. + +**Strong preference: keep this client-side if possible.** In an ideal world, the LLM integration is purely a TypeScript/client-side concern — no Grails changes needed. This would make the project simpler, faster to iterate on, and easier for other developers to run locally. Evaluate whether a client-side-only approach is viable, e.g.: +- Client-side LLM SDK calls with a user-provided or locally-configured API key (fine for internal POC / local dev). +- A lightweight browser-based key management scheme (e.g., user pastes their own key, stored in localStorage — acceptable for a dev-facing POC, not for production). + +**For deployed Toolbox:** Toolbox is on the public internet, requires registration, but is low-volume — the audience is limited to trusted clients, interested customers, and candidates. It is not a high-abuse-risk environment, but API keys still shouldn't be shipped in the client bundle. If a server-side proxy is needed for production, the Toolbox **Grails backend** is available. Adding a new controller + service to relay LLM calls is a natural fit. + +Before planning any server-side work, consult the **hoist-core documentation** at `../hoist-core/docs/`: +- `http-client.md` — Hoist's server-side HTTP client (for making outbound calls to LLM APIs). +- `request-flow.md` — How requests flow through the Hoist server stack. +- `configuration.md` — Hoist Config system (for storing API keys, model selection, rate limits). +- `authentication.md` / `authorization.md` — How Toolbox authenticates users (relevant for per-user rate limiting). + +**Questions to answer:** +- Which LLM provider(s) to support? (Anthropic, OpenAI, etc.) Recommend a primary + a fallback. +- Can we keep it client-side only? What are the tradeoffs vs a Grails proxy? +- API key management: for local POC, a user-provided key is fine. For deployed Toolbox, where does the key live? Hoist Config? Environment variable? +- Rate limiting / cost controls: given the low-volume, trusted audience, how lightweight can this be? +- Latency budget: what's acceptable for a chat-style interaction? + +**Phasing:** +1. **JSON harness (unblocked):** a human relays specs between an external LLM and the dashboard manually. Validates the schema and prompt strategy with zero integration work. +2. **Client-side POC:** wire up LLM calls directly from TypeScript with a locally-configured API key. Good enough for internal demos and developer walkthroughs. +3. **Deployed proxy (if needed):** Grails controller + service for production Toolbox. Only build this if client-side-only isn't viable for the deployed environment. + +**Deliverable:** a decision memo — recommended LLM provider, client-side vs server-side architecture decision with rationale, key management approach, and implementation plan for whichever path is chosen. + +--- + +## Output requirements + +### Where to write + +Write all planning output to **`docs/planning/weather-v2/`** — the same directory as this prompt. Use separate files for distinct artifacts so they're easy to navigate and reference during implementation. + +### Required files + +1. **`PLAN.md`** — The master plan. Implementation phases with milestones, dependencies, and sequencing. (e.g., V2 scaffolding → DSL spec → widgets → wiring → JSON harness → LLM harness → polish.) Include the V2 project scaffolding (new directory, entry point, nav registration) as the first phase. This is the primary deliverable — it should be detailed enough that an implementation agent can pick up any phase and execute it. + +2. **`ROADMAP.md`** — A concise phase-by-phase overview with estimated scope and key decision points. Higher-level than the plan — useful for stakeholder communication and progress tracking. + +3. **`TASKS.md`** — A structured task list / checklist derived from the plan. Organized by phase, with clear done-criteria for each task. This is the day-to-day execution tracking artifact. + +4. **`WIDGET-CATALOG.md`** — The widget catalog: type, purpose, inputs, outputs, config keys, default behavior. Include at least 5 example widget JSON fragments. + +5. **`WIDGET-SCHEMA.md`** — The widget schema interface design: how widgets declare their configuration surface, example schemas for 2-3 widget types across the complexity range, and a discussion of the auto-generation stretch goal (what Hoist extension points would enable it, what a manually maintained version looks like in the meantime). + +6. **`DSL-SPEC.md`** — The dashboard spec schema (formal JSON Schema or equivalent), validation strategy, migration/versioning approach, and example full dashboard specs. + +7. **`WIRING-DESIGN.md`** — Inter-widget IO/wiring design: the formalism, runtime propagation model, validation rules, and example JSON fragments. + +8. **`RISKS.md`** — Risk list with mitigations (LLM invalid output rates, schema drift, wiring complexity, UI confusion, etc.). + +9. **`DEMO-SCRIPTS.md`** — 3–5 "customer wow" scenarios written as exact chat prompts + expected resulting dashboards. + +10. **`DEPLOYMENT-MEMO.md`** — Deployment research memo with 2–3 viable options, pros/cons, and recommended path. + +Additional files are fine if they help organize the output (e.g., a Hoist conventions checklist, UI wireframe descriptions). Use your judgment. + +**Planning style:** Be opinionated. Where there are multiple valid approaches, pick one, justify it briefly, and move on. Do not leave decisions open-ended or hedge with "we could also..." alternatives unless there is a genuine tradeoff the implementer needs to decide at build time. diff --git a/client-app/src/examples/weatherv2/planning/RISKS.md b/client-app/src/examples/weatherv2/planning/RISKS.md new file mode 100644 index 000000000..23b489f49 --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/RISKS.md @@ -0,0 +1,147 @@ +# Risk Register — Weather Dashboard V2 + +## High Priority + +### R1: LLM Produces Invalid Specs + +**Risk:** The LLM generates JSON that fails validation — malformed structure, dangling widget references, invalid config values, layout overlaps. + +**Likelihood:** High (expected during initial development, moderate in production). + +**Impact:** Dashboard fails to hydrate. User sees error instead of result. + +**Mitigations:** +- Hard validation gate — invalid specs never reach `setPersistableState()`. +- Detailed error messages with JSON paths guide the LLM (or human) to fix issues. +- System prompt includes the full JSON Schema and explicit examples. +- LLM repair loop: on validation failure, send errors back to the LLM with "fix this" instruction (max 2 retries). +- Full-spec replacement (not patches) — easier for the LLM to get right. +- Fallback: the JSON harness always works for manual editing. + +### R2: Wiring Complexity Confuses Users + +**Risk:** The binding model (fromWidget/output references, instance IDs) is too abstract for non-technical users or for LLMs to handle reliably with many widgets. + +**Likelihood:** Medium. + +**Impact:** Users can't build interesting dashboards; the demo falls flat. + +**Mitigations:** +- Keep the widget set small (9 types) with clear input/output contracts. +- Provide 3-5 curated example specs that users can start from and modify. +- The Dashboard Inspector widget makes bindings visible and debuggable. +- LLM harness abstracts wiring — users say "show me New York weather" not "bind city input to cityChooser output." +- Design sensible defaults — display widgets work with no bindings (using default city). + +### R3: DashCanvasModel Instance ID Instability + +**Risk:** DashCanvasModel generates instance IDs based on widget order. If the LLM reorders widgets in a spec update, IDs shift and bindings break silently. + +**Likelihood:** Medium. + +**Impact:** Bindings reference wrong widgets or become dangling. Dashboard behaves unexpectedly. + +**Mitigations:** +- System prompt instructs the LLM to maintain widget order when editing. +- Validation catches dangling references before hydration. +- Consider a pre-processing step that assigns stable IDs from widget titles or explicit labels (stretch goal). +- Document the ID assignment rule clearly in the LLM prompt. + +## Medium Priority + +### R4: Schema Drift Between Widget Code and Meta + +**Risk:** Widget models evolve (new config properties, changed defaults) but the static `meta` object isn't updated. Schema and reality diverge. + +**Likelihood:** Medium (grows over time). + +**Impact:** LLM generates specs with stale config options; validation passes but widget ignores unknown config. + +**Mitigations:** +- Co-locate `meta` with the widget model class — change the code, update the meta in the same file. +- Add a dev-time test that instantiates each widget and verifies its `meta.config` keys match its actual `@persist`/`@bindable` properties. +- Plan for auto-generation (see WIDGET-SCHEMA.md stretch goal) to eliminate manual sync. + +### R5: Performance with Many Widgets + +**Risk:** A dashboard with 15+ widgets, each with bindings, creates a dense MobX reaction graph. Changes to a shared output (e.g., city) trigger many simultaneous reloads. + +**Likelihood:** Low (typical dashboards have 5-10 widgets). + +**Impact:** UI jank, slow response to input changes. + +**Mitigations:** +- Data caching: `WeatherDataModel` caches API responses by city. Multiple widgets requesting the same city share one cached response. +- Debounce: Widget reload reactions are debounced (300ms). +- MobX is efficient — computed invalidation is O(1) per dependency edge. +- DashCanvas only renders visible widgets (react-grid-layout handles virtualization for scrolled-out widgets). + +### R6: OpenWeatherMap API Limitations + +**Risk:** Free tier rate limits (60 calls/min, 1M calls/month) may be hit during demos with many cities. API outages affect the demo. + +**Likelihood:** Low (server-side caching mitigates). + +**Impact:** Weather data unavailable; display widgets show errors. + +**Mitigations:** +- Server-side caching (10 min current, 30 min forecast) — already in V1. +- Extend caching to V2's multi-city pattern: cache per city, not per request. +- Consider a deterministic mock data mode for demo stability (see R7). +- 25 cities × 2 endpoints = 50 unique cache entries — well within free tier. + +### R7: Demo Instability from Live Data + +**Risk:** Live weather data changes between demo rehearsal and presentation. Dashboard looks different than expected. API returns errors during live demo. + +**Likelihood:** Medium. + +**Impact:** Demo doesn't match rehearsed script. Embarrassing during customer presentations. + +**Mitigations:** +- **Mock data mode:** Server-side flag (Hoist Config `weatherMockMode: boolean`) that returns stable, pre-recorded weather data. Data is realistic and covers interesting conditions (rain, wind, temperature variation). +- Mock data checked into the repo as JSON fixtures. +- Toggle via Admin console — no code change needed to switch. +- Live mode remains the default; mock mode is opt-in for demos. + +## Low Priority + +### R8: V1/V2 Server-Side Conflicts + +**Risk:** Extending `WeatherService.groovy` for V2 (new data formats, additional endpoints) breaks V1's existing endpoints. + +**Likelihood:** Low (V2 additions are additive). + +**Impact:** V1 stops working, breaking the A/B comparison story. + +**Mitigations:** +- New endpoints only — don't modify existing `weather/current` or `weather/forecast`. +- V2 can use the same endpoints (the data shape is sufficient) or add new ones (e.g., `weather/v2/forecast` with normalized output). +- Test V1 after any server changes. + +### R9: Persistence Provider Conflicts + +**Risk:** V2's wiring data (`bindings` in viewState) interferes with Hoist's persistence providers or causes unexpected behavior when ViewManager saves/loads views. + +**Likelihood:** Low. `viewState` is an opaque `PlainObject` — Hoist doesn't inspect its contents. + +**Impact:** Saved views lose wiring information or behave differently on reload. + +**Mitigations:** +- `bindings` is just another key in `viewState` — treated identically to any other widget state by Hoist's persistence pipeline. +- Test save/load/share cycles explicitly for dashboards with wiring. +- Verify that ViewManager round-trips preserve the full `viewState` including nested `bindings`. + +### R10: LLM Cost Overrun + +**Risk:** Users (or automated tests) make excessive LLM calls, running up API costs. + +**Likelihood:** Low (small, trusted user base). + +**Impact:** Unexpected Anthropic API bill. + +**Mitigations:** +- Per-user rate limit (20 req/hour) enforced server-side. +- Max tokens capped at 4096 per request. +- Monitor via Anthropic dashboard; set billing alerts. +- API key stored in Hoist Config — can be rotated or disabled instantly via Admin console. diff --git a/client-app/src/examples/weatherv2/planning/ROADMAP.md b/client-app/src/examples/weatherv2/planning/ROADMAP.md new file mode 100644 index 000000000..7d588f49b --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/ROADMAP.md @@ -0,0 +1,94 @@ +# Weather Dashboard V2 — Roadmap + +## Phase Overview + +| Phase | Name | Key Deliverable | Scope | Dependencies | +|-------|------|----------------|-------|-------------| +| 1 | **Scaffolding** | App loads at `/weatherv2` with empty DashCanvas | Small | None | +| 2 | **Wiring Infrastructure** | WiringModel, WeatherWidgetModel base, WidgetRegistry | Medium | Phase 1 | +| 3 | **Initial Widgets** | 5 widgets (city chooser + 4 display), WeatherDataModel | Large | Phase 2 | +| 4 | **Remaining Widgets** | 4 more widgets (units, wind, markdown, inspector) | Medium | Phase 3 | +| 5 | **Validation + JSON Harness** | Spec validation pipeline + JSON editor UI | Medium-Large | Phase 4 | +| 6 | **LLM Integration** | Grails proxy + chat harness + prompt engineering | Large | Phase 5 | +| 7 | **Polish & Demo Prep** | Demo-quality UX, example specs, changelog | Medium | Phase 6 | + +## Key Decision Points + +| Decision | Phase | Options | Chosen | +|----------|-------|---------|--------| +| Spec format | Design | New format vs. native Hoist state | Native Hoist state | +| Wiring storage | Design | Separate wiring layer vs. in viewState | In viewState (`bindings` key) | +| Data access | 3 | Per-widget fetch vs. shared cache | Shared `WeatherDataModel` | +| JSON editor | 5 | CodeMirror/Monaco vs. textarea | Start with textarea, upgrade if time | +| LLM provider | 6 | Anthropic vs. OpenAI | Anthropic (Claude) primary | +| LLM protocol | 6 | Full-spec replacement vs. JSON Patch | Full-spec replacement | +| LLM proxy | 6 | Client-side vs. Grails proxy | Grails proxy (CORS requirement) | + +## Phase Details + +### Phase 1: Scaffolding +- New directory: `client-app/src/examples/weatherv2/` +- Entry point: `apps/weatherv2.ts` +- Nav registration in `ExamplesTabModel.ts` +- Empty `AppModel` + `AppComponent` + `WeatherV2DashModel` +- **Done when:** App loads at `/weatherv2` with empty DashCanvas and ViewManager controls. + +### Phase 2: Wiring Infrastructure +- `WiringModel` — observable output map, publish/resolve/remove +- `WeatherWidgetModel` — base class with `resolveInput()` / `publishOutput()` +- `WidgetRegistry` — register, lookup, prompt generation +- TypeScript type definitions for all spec/wiring types +- Basic validation skeleton +- **Done when:** A test widget can publish an output and another test widget can resolve it reactively. + +### Phase 3: Initial Widgets +- `WeatherDataModel` — shared per-city data cache with normalized types +- `CityChooserWidget` — dropdown, publishes `selectedCity` +- `CurrentConditionsWidget` — temp gauge + details, consumes `city` + `units` +- `ForecastChartWidget` — multi-series chart, consumes `city` + `units` +- `PrecipChartWidget` — precip probability + volume, consumes `city` +- `SummaryGridWidget` — daily overview grid, consumes `city` + `units` +- Default dashboard layout with all 5 widgets wired together +- **Done when:** Changing the city in the chooser updates all display widgets. Layout persists. + +### Phase 4: Remaining Widgets +- `UnitsToggleWidget` — imperial/metric toggle, publishes `units` +- `WindChartWidget` — wind speed + gusts chart, consumes `city` + `units` +- `MarkdownContentWidget` — static rich-text display +- `DashInspectorWidget` — debug view of wiring graph + values +- Update default layout to include all 9 widgets +- **Done when:** Full widget set functional. Inspector shows live binding values. + +### Phase 5: Validation + JSON Harness +- Full 3-stage validation pipeline (structural, semantic, referential) +- JSON Schema generation from WidgetRegistry +- JSON editor panel (split-pane: editor + live dashboard) +- Apply/validate/hydrate flow +- 5 curated example specs +- Export (copy spec to clipboard) +- **Done when:** User can paste a JSON spec, see validation errors or a live dashboard. + +### Phase 6: LLM Integration +- `LlmController.groovy` + `LlmService.groovy` — thin proxy to Anthropic API +- Hoist Config entries for API key, model, limits +- Client-side `LlmChatService` — prompt assembly + response parsing +- Chat harness UI (embedded panel with message history) +- System prompt with widget schemas + spec format + examples +- Error recovery (validation errors → retry prompt) +- **Done when:** User types "build me a weather dashboard" and it appears. + +### Phase 7: Polish & Demo Prep +- Default dashboard looks great out of the box +- Error states and loading masks +- Light/dark theme support +- Walk through all 5 demo scripts successfully +- Changelog entry +- Mock data mode (stretch) +- **Done when:** Ready to demo to customers. + +## Risk Summary + +See RISKS.md for full details. Top 3: +1. **LLM produces invalid specs** — mitigated by hard validation gate + repair loop. +2. **Wiring complexity confuses users** — mitigated by curated examples + LLM abstraction. +3. **Instance ID instability** — mitigated by ordering rules + validation. diff --git a/client-app/src/examples/weatherv2/planning/TASKS.md b/client-app/src/examples/weatherv2/planning/TASKS.md new file mode 100644 index 000000000..d851ded37 --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/TASKS.md @@ -0,0 +1,88 @@ +# Tasks — Weather Dashboard V2 + +## Phase 1: Scaffolding + +- [ ] **1.1** Create `client-app/src/examples/weatherv2/` directory structure +- [ ] **1.2** Create `apps/weatherv2.ts` entry point with `XH.renderApp()` +- [ ] **1.3** Create `BaseAppModel` extension (`AppModel.ts`) with `ViewManagerModel` setup +- [ ] **1.4** Create `AppComponent.ts` with `appBar` + empty `dashCanvas` +- [ ] **1.5** Create `WeatherV2DashModel.ts` with empty `DashCanvasModel` + `persistWith` +- [ ] **1.6** Copy `Types.ts` and `Icons.ts` from V1 +- [ ] **1.7** Create `WeatherV2.scss` with base styles +- [ ] **1.8** Register V2 in `ExamplesTabModel.ts` +- [ ] **1.9** Verify app loads at `/weatherv2` with empty canvas and ViewManager + +## Phase 2: Wiring Infrastructure + +- [ ] **2.1** Create `dash/types.ts` with `WidgetMeta`, `BindingSpec`, `InputDef`, `OutputDef`, `DashSpec`, `ValidationResult` types +- [ ] **2.2** Implement `dash/WiringModel.ts` — observable output map, publishOutput, resolveBinding, removeWidget +- [ ] **2.3** Implement `dash/WeatherWidgetModel.ts` — base class with resolveInput, publishOutput, onLinked persistence setup +- [ ] **2.4** Implement `dash/WidgetRegistry.ts` — register, get, getAll, generateLLMPrompt +- [ ] **2.5** Implement basic `dash/validation.ts` — structural checks (known viewSpecIds), referential checks (binding targets exist) +- [ ] **2.6** Wire WiringModel into WeatherV2DashModel + +## Phase 3: Initial Widgets + +- [ ] **3.1** Implement `WeatherDataModel.ts` — per-city cache, ensureDataAsync, normalize, observable cache map +- [ ] **3.2** Define `NormalizedCurrent` and `NormalizedForecastEntry` types in `Types.ts` +- [ ] **3.3** Implement `widgets/CityChooserWidget.ts` — model + component, publishes selectedCity +- [ ] **3.4** Implement `widgets/CurrentConditionsWidget.ts` — temp gauge + details, consumes city + units +- [ ] **3.5** Implement `widgets/ForecastChartWidget.ts` — multi-series line chart, consumes city + units +- [ ] **3.6** Implement `widgets/PrecipChartWidget.ts` — dual-axis precip chart, consumes city +- [ ] **3.7** Implement `widgets/SummaryGridWidget.ts` — daily overview grid, consumes city + units +- [ ] **3.8** Register all 5 widgets in WidgetRegistry with static `meta` +- [ ] **3.9** Add viewSpecs to DashCanvasModel for all 5 widgets +- [ ] **3.10** Set up default initialState layout +- [ ] **3.11** End-to-end test: city change propagates to all display widgets via wiring + +## Phase 4: Remaining Widgets + +- [ ] **4.1** Implement `widgets/UnitsToggleWidget.ts` — imperial/metric toggle, publishes units +- [ ] **4.2** Implement `widgets/WindChartWidget.ts` — wind speed + gusts chart, consumes city + units +- [ ] **4.3** Implement `widgets/MarkdownContentWidget.ts` — markdown renderer, config-driven +- [ ] **4.4** Implement `widgets/DashInspectorWidget.ts` — wiring graph visualization +- [ ] **4.5** Register all 4 new widgets in WidgetRegistry +- [ ] **4.6** Add viewSpecs to DashCanvasModel +- [ ] **4.7** Update default initialState layout to include key widgets +- [ ] **4.8** Verify units toggle drives unit conversion in all applicable widgets +- [ ] **4.9** Verify inspector shows live binding values and updates reactively + +## Phase 5: Validation + JSON Harness + +- [ ] **5.1** Add `ajv` dependency for JSON Schema validation +- [ ] **5.2** Implement full structural validation (JSON Schema against spec) +- [ ] **5.3** Implement semantic validation (config types, enums, required fields) +- [ ] **5.4** Implement referential validation (binding targets, output existence, type compat, cycle detection) +- [ ] **5.5** Implement spec version checking and migration framework +- [ ] **5.6** Create JSON harness panel component — split pane with editor + dashboard +- [ ] **5.7** Implement Apply button flow: parse → migrate → validate → hydrate +- [ ] **5.8** Implement validation error display with JSON paths +- [ ] **5.9** Implement "Copy Spec" export (getPersistableState → clipboard) +- [ ] **5.10** Create 5 curated example specs (basic, full, comparison, minimal, annotated) +- [ ] **5.11** Implement "Load Example" dropdown +- [ ] **5.12** End-to-end test: paste a spec, see it hydrate; paste invalid spec, see errors + +## Phase 6: LLM Integration + +- [ ] **6.1** Create `LlmController.groovy` with `generate` endpoint +- [ ] **6.2** Create `LlmService.groovy` — Anthropic API calls via JSONClient +- [ ] **6.3** Add Hoist Config entries (llmApiKey, llmProvider, llmModel, llmMaxTokens) +- [ ] **6.4** Implement server-side rate limiting (per-user, 20 req/hour) +- [ ] **6.5** Build client-side `LlmChatService` — prompt assembly, response parsing +- [ ] **6.6** Write system prompt template with widget schemas + spec format + rules +- [ ] **6.7** Create chat harness UI component — message history + input + send +- [ ] **6.8** Implement chat flow: user message → build prompt → call proxy → parse spec → validate → hydrate +- [ ] **6.9** Implement error recovery — validation failures shown in chat, optional retry +- [ ] **6.10** Test with all 5 demo scripts from DEMO-SCRIPTS.md + +## Phase 7: Polish & Demo Prep + +- [ ] **7.1** Finalize default dashboard layout and initial experience +- [ ] **7.2** Add loading masks and error states to all widgets +- [ ] **7.3** Verify light/dark theme support +- [ ] **7.4** Review and finalize all 5 example specs +- [ ] **7.5** Walk through demo scripts end-to-end, fix issues +- [ ] **7.6** Add CHANGELOG.md entry for V2 +- [ ] **7.7** Final code review pass — conventions checklist, style, naming +- [ ] **7.8** (Stretch) Implement deterministic mock data mode +- [ ] **7.9** (Stretch) LLM response streaming for better perceived latency diff --git a/client-app/src/examples/weatherv2/planning/WIDGET-CATALOG.md b/client-app/src/examples/weatherv2/planning/WIDGET-CATALOG.md new file mode 100644 index 000000000..ae6d4d950 --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/WIDGET-CATALOG.md @@ -0,0 +1,362 @@ +# Widget Catalog — Weather Dashboard V2 + +## Summary Table + +| # | Type ID | Title | Category | Inputs | Outputs | Key Config | Purpose | +|---|---------|-------|----------|--------|---------|-----------|---------| +| 1 | `cityChooser` | City Chooser | input | — | `selectedCity` (string) | selectedCity, cities, enableSearch | Primary city selection | +| 2 | `unitsToggle` | Units Toggle | input | — | `units` (string) | units | Switch imperial/metric | +| 3 | `currentConditions` | Current Conditions | display | city, units | — | showFeelsLike, showDetails, displayMode | Current temp gauge + conditions | +| 4 | `forecastChart` | Forecast Chart | display | city, units | — | series, chartType, showLegend | Multi-series forecast line chart | +| 5 | `precipChart` | Precipitation | display | city | — | metric, showThresholds | Precip probability + volume | +| 6 | `windChart` | Wind | display | city, units | — | showGusts, chartType | Wind speed/gusts over time | +| 7 | `summaryGrid` | 5-Day Summary | display | city, units | — | visibleColumns | Tabular daily overview | +| 8 | `markdownContent` | Markdown Content | utility | — | — | content | Static rich-text display | +| 9 | `dashInspector` | Dashboard Inspector | utility | — | — | showBindings, showOutputValues | Debug: wiring graph + values | + +--- + +## 1. City Chooser (`cityChooser`) + +**Purpose:** Dropdown selector for city. The primary input widget — most display widgets bind their `city` input to this widget's output. + +**Outputs:** +- `selectedCity` (string) — The currently selected city name. + +**Config:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `selectedCity` | string | `"New York"` | Initially selected city | +| `cities` | string[] | *(full default list)* | Available cities. Omit for all 25 defaults | +| `enableSearch` | boolean | `true` | Type-ahead filtering in dropdown | + +**Default size:** 3×2 (compact — fits in a sidebar column) + +**Wiring examples:** +- Single chooser driving 5 display widgets — the basic dashboard. +- Two choosers side-by-side, each driving its own column of widgets — city comparison. +- Chooser with constrained `cities` list — regional focus. + +**Example state:** +```json +{ + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "title": "Select City", + "state": { + "selectedCity": "San Francisco", + "cities": ["San Francisco", "Los Angeles", "Seattle", "Portland"] + } +} +``` + +--- + +## 2. Units Toggle (`unitsToggle`) + +**Purpose:** Toggle between imperial and metric units. Display widgets that accept a `units` input will adapt their display accordingly. + +**Outputs:** +- `units` (string) — `"imperial"` or `"metric"`. + +**Config:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `units` | enum | `"imperial"` | Initial unit system. Values: `imperial`, `metric` | + +**Default size:** 3×2 + +**Wiring examples:** +- One toggle driving all display widgets — global unit preference. +- No toggle present — widgets default to imperial. +- Const binding `{"const": "metric"}` — hardcoded unit without a toggle widget. + +**Example state:** +```json +{ + "viewSpecId": "unitsToggle", + "layout": {"x": 9, "y": 0, "w": 3, "h": 2}, + "state": {"units": "metric"} +} +``` + +--- + +## 3. Current Conditions (`currentConditions`) + +**Purpose:** Current weather snapshot — temperature gauge, conditions icon, description, and key details (feels-like, humidity, wind). + +**Inputs:** + +| Input | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `city` | string | yes | `"New York"` | City to display | +| `units` | string | no | `"imperial"` | Unit system | + +**Config:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `showFeelsLike` | boolean | `true` | Show feels-like temperature | +| `showHumidity` | boolean | `true` | Show humidity percentage | +| `showWind` | boolean | `true` | Show wind speed | +| `displayMode` | enum | `"detailed"` | `"detailed"` (gauge + details) or `"compact"` (summary only) | + +**Default size:** 4×5 + +**Example state:** +```json +{ + "viewSpecId": "currentConditions", + "layout": {"x": 0, "y": 2, "w": 4, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "showFeelsLike": true, + "showHumidity": true, + "displayMode": "detailed" + } +} +``` + +--- + +## 4. Forecast Chart (`forecastChart`) + +**Purpose:** Multi-series line/area/column chart showing forecast data over the 5-day window. The primary charting widget — highly configurable series selection and chart types. + +**Inputs:** + +| Input | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `city` | string | yes | `"New York"` | City to show forecast for | +| `units` | string | no | `"imperial"` | Unit system | + +**Config:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `series` | string[] | `["temp", "feelsLike"]` | Series to show. Options: `temp`, `feelsLike`, `humidity`, `pressure` | +| `chartType` | enum | `"line"` | `"line"`, `"area"`, `"column"` | +| `showLegend` | boolean | `true` | Show chart legend | +| `showTooltip` | boolean | `true` | Show tooltips on hover | + +**Default size:** 8×5 + +**Wiring examples:** +- Bound to city chooser — standard forecast view. +- Two instances side-by-side, each bound to a different city — comparison. +- Configured with `series: ["humidity", "pressure"]` — repurposed as humidity/pressure chart. + +**Example state:** +```json +{ + "viewSpecId": "forecastChart", + "layout": {"x": 4, "y": 0, "w": 8, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line", + "showLegend": true + } +} +``` + +--- + +## 5. Precipitation Chart (`precipChart`) + +**Purpose:** Precipitation probability and/or volume over the forecast period. Dual-axis column chart. + +**Inputs:** + +| Input | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `city` | string | yes | `"New York"` | City to show precipitation for | + +**Config:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `metric` | enum | `"both"` | `"probability"`, `"volume"`, `"both"` | +| `showThresholds` | boolean | `false` | Highlight high-probability periods | + +**Default size:** 6×5 + +**Example state:** +```json +{ + "viewSpecId": "precipChart", + "layout": {"x": 0, "y": 5, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"} + }, + "metric": "both", + "showThresholds": true + } +} +``` + +--- + +## 6. Wind Chart (`windChart`) + +**Purpose:** Wind speed and gusts over the forecast period. + +**Inputs:** + +| Input | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `city` | string | yes | `"New York"` | City to show wind data for | +| `units` | string | no | `"imperial"` | Unit system (mph vs m/s) | + +**Config:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `showGusts` | boolean | `true` | Show gust data alongside sustained | +| `chartType` | enum | `"line"` | `"line"`, `"area"` | + +**Default size:** 6×5 + +**Example state:** +```json +{ + "viewSpecId": "windChart", + "layout": {"x": 6, "y": 5, "w": 6, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "showGusts": true, + "chartType": "line" + } +} +``` + +--- + +## 7. 5-Day Summary Grid (`summaryGrid`) + +**Purpose:** Tabular daily overview — one row per day with high/low, conditions, humidity, wind. + +**Inputs:** + +| Input | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `city` | string | yes | `"New York"` | City to summarize | +| `units` | string | no | `"imperial"` | Unit system | + +**Config:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `visibleColumns` | string[] | `["date","icon","conditions","high","low","humidity","wind"]` | Columns to display | + +**Default size:** 6×5 + +**Example state:** +```json +{ + "viewSpecId": "summaryGrid", + "layout": {"x": 0, "y": 10, "w": 12, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "visibleColumns": ["date", "conditions", "high", "low"] + } +} +``` + +--- + +## 8. Markdown Content (`markdownContent`) + +**Purpose:** Static rich-text display using Hoist's Markdown renderer. Useful for dashboard titles, instructions, annotations, or any static content. No data inputs — purely content-driven. + +**Config:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `content` | string | `"# Welcome\nEdit this widget's content."` | Markdown text to render | + +**Default size:** 4×3 + +**Wiring examples:** +- Dashboard header with title and instructions. +- Annotation panel explaining what the dashboard shows. +- Placeholder for LLM-generated content (static initially, could be dynamic later). + +**Example state:** +```json +{ + "viewSpecId": "markdownContent", + "layout": {"x": 0, "y": 0, "w": 12, "h": 2}, + "state": { + "content": "## Weather Comparison Dashboard\nComparing current conditions and forecasts between two cities. Use the city selectors to change locations." + } +} +``` + +--- + +## 9. Dashboard Inspector (`dashInspector`) + +**Purpose:** Debug/demo utility that visualizes the wiring graph, shows resolved input/output values, and displays validation status. Makes the IO story visible and legible during demos. + +**Config:** + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `showBindings` | boolean | `true` | Show binding details per widget | +| `showOutputValues` | boolean | `true` | Show current output values | +| `showValidation` | boolean | `true` | Show validation errors/warnings | +| `compactMode` | boolean | `false` | Compact single-line display | + +**Default size:** 4×6 + +**Implementation notes:** +- Reads from `WiringModel` outputs map and `DashCanvasModel` view state. +- Uses a simple tree/list display — not a visual graph (keep it implementable). +- Shows each widget: ID, type, bound inputs (resolved values), published outputs (current values), validation status. +- Updates reactively as bindings and values change. + +**Example state:** +```json +{ + "viewSpecId": "dashInspector", + "layout": {"x": 8, "y": 0, "w": 4, "h": 10}, + "state": { + "showBindings": true, + "showOutputValues": true, + "showValidation": true + } +} +``` + +--- + +## Compositional Range + +The 9-widget set enables these key compositions: + +| Composition | Widgets Used | What It Demonstrates | +|-------------|-------------|---------------------| +| **Basic weather dashboard** | 1 city chooser + 5 display widgets | Single input driving multiple outputs | +| **City comparison** | 2 city choosers + 2×(conditions + forecast) | Small multiples, independent bindings | +| **Metric/imperial toggle** | Units toggle + all display widgets | Global preference via shared binding | +| **Focused views** | Chooser + single chart (configured differently) | Config knobs change widget behavior | +| **Annotated dashboard** | Markdown + display widgets | Static content alongside live data | +| **Debuggable dashboard** | Any layout + inspector | Wiring visibility for demos | +| **Constant bindings** | Display widgets with `const` bindings | No input widgets needed, static city | diff --git a/client-app/src/examples/weatherv2/planning/WIDGET-SCHEMA.md b/client-app/src/examples/weatherv2/planning/WIDGET-SCHEMA.md new file mode 100644 index 000000000..e09ecc8ff --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/WIDGET-SCHEMA.md @@ -0,0 +1,298 @@ +# Widget Schema Interface Design + +## Purpose + +Each widget type needs a machine-readable description of its configuration surface — its **schema**. This is what: +- The **LLM** consumes to produce valid dashboard specs. +- The **JSON harness** validates against. +- The **Dashboard Inspector** widget displays for debugging. + +## Design Decision: Static `meta` Property + +Each widget model class declares a static `meta` property of type `WidgetMeta`. This is manually maintained per widget. The registry collects all `meta` objects and exposes them for validation and LLM prompt generation. + +**Why this approach:** +- Simple, explicit, co-located with the widget code. +- No runtime introspection magic — easy to debug. +- Clean enough to auto-generate later (the `meta` shape is stable; the source could change from hand-written to generated). +- JSON Schema fragments are embedded directly — no translation layer. + +## WidgetMeta Interface + +```typescript +interface WidgetMeta { + /** Widget type ID — matches the DashCanvasViewSpec.id. */ + id: string; + + /** Human-readable name. */ + title: string; + + /** Short description of what this widget does. */ + description: string; + + /** Category for grouping in menus/docs. */ + category: 'input' | 'display' | 'utility'; + + /** Inputs this widget accepts (consumed via bindings). */ + inputs: InputDef[]; + + /** Outputs this widget publishes (available for binding by other widgets). */ + outputs: OutputDef[]; + + /** Configurable properties (persisted in viewState). */ + config: Record; + + /** Default layout dimensions (columns × rows). */ + defaultSize: {w: number; h: number}; + + /** Sizing constraints. */ + minSize?: {w?: number; h?: number}; + maxSize?: {w?: number; h?: number}; +} + +interface InputDef { + name: string; + type: string; + required?: boolean; + default?: any; + description: string; +} + +interface OutputDef { + name: string; + type: string; + description: string; +} + +interface ConfigPropertyDef { + type: 'string' | 'number' | 'boolean' | 'enum' | 'string[]'; + description: string; + default?: any; + enum?: string[]; // For type: 'enum' + min?: number; // For type: 'number' + max?: number; // For type: 'number' + required?: boolean; +} +``` + +## Widget Registry + +A singleton registry collects all widget schemas and provides lookup: + +```typescript +class WidgetRegistry { + private _schemas = new Map(); + + register(meta: WidgetMeta) { + this._schemas.set(meta.id, meta); + } + + get(id: string): WidgetMeta | undefined { + return this._schemas.get(id); + } + + getAll(): WidgetMeta[] { + return Array.from(this._schemas.values()); + } + + /** Generate a JSON Schema for the full dashboard spec. */ + generateDashboardSchema(): object { /* see DSL-SPEC.md */ } + + /** Generate a prompt-friendly text description of all widget types. */ + generateLLMPrompt(): string { /* see LLM harness section */ } +} +``` + +Registration happens at module load time — each widget file calls `widgetRegistry.register(MyWidgetModel.meta)`. + +## Example Schemas + +### 1. City Chooser (Simple Input Widget) + +```typescript +static meta: WidgetMeta = { + id: 'cityChooser', + title: 'City Chooser', + description: 'Dropdown selector that emits the selected city name. Other widgets bind to this to display data for the chosen city.', + category: 'input', + inputs: [], + outputs: [ + {name: 'selectedCity', type: 'string', description: 'The currently selected city name.'} + ], + config: { + selectedCity: { + type: 'string', + description: 'Initially selected city.', + default: 'New York' + }, + cities: { + type: 'string[]', + description: 'List of available cities. If omitted, uses the full default city list.', + required: false + }, + enableSearch: { + type: 'boolean', + description: 'Enable type-ahead filtering in the dropdown.', + default: true + } + }, + defaultSize: {w: 3, h: 2}, + minSize: {w: 2, h: 1} +}; +``` + +### 2. Forecast Chart (Complex Display Widget) + +```typescript +static meta: WidgetMeta = { + id: 'forecastChart', + title: 'Forecast Chart', + description: 'Multi-series line/area chart showing forecast data over time. Configurable series selection and chart type.', + category: 'display', + inputs: [ + {name: 'city', type: 'string', required: true, default: 'New York', description: 'City to show forecast for.'}, + {name: 'units', type: 'string', required: false, default: 'imperial', description: 'Unit system: "imperial" or "metric".'} + ], + outputs: [], + config: { + series: { + type: 'string[]', + description: 'Which data series to display. Options: "temp", "feelsLike", "humidity", "pressure".', + default: ['temp', 'feelsLike'] + }, + chartType: { + type: 'enum', + description: 'Chart rendering style.', + enum: ['line', 'area', 'column'], + default: 'line' + }, + showLegend: { + type: 'boolean', + description: 'Show chart legend.', + default: true + }, + showTooltip: { + type: 'boolean', + description: 'Show data tooltips on hover.', + default: true + } + }, + defaultSize: {w: 8, h: 5}, + minSize: {w: 4, h: 3} +}; +``` + +### 3. Dashboard Inspector (Utility Widget) + +```typescript +static meta: WidgetMeta = { + id: 'dashInspector', + title: 'Dashboard Inspector', + description: 'Debug utility that shows the current wiring graph, resolved input/output values, and validation status for all widgets in the dashboard.', + category: 'utility', + inputs: [], + outputs: [], + config: { + showBindings: { + type: 'boolean', + description: 'Show binding details for each widget.', + default: true + }, + showOutputValues: { + type: 'boolean', + description: 'Show current resolved output values.', + default: true + }, + showValidation: { + type: 'boolean', + description: 'Show validation status (errors/warnings).', + default: true + }, + compactMode: { + type: 'boolean', + description: 'Use compact single-line display per widget.', + default: false + } + }, + defaultSize: {w: 4, h: 6}, + minSize: {w: 3, h: 4} +}; +``` + +## LLM Prompt Generation + +The registry generates a structured text description for the LLM system prompt: + +``` +## Available Widget Types + +### cityChooser — City Chooser [input] +Dropdown selector that emits the selected city name. +Outputs: selectedCity (string) — The currently selected city name. +Config: + - selectedCity (string, default: "New York") — Initially selected city. + - cities (string[], optional) — List of available cities. + - enableSearch (boolean, default: true) — Enable type-ahead filtering. +Default size: 3×2 + +### forecastChart — Forecast Chart [display] +Multi-series line/area chart showing forecast data over time. +Inputs: + - city (string, required, default: "New York") — City to show forecast for. + - units (string, optional, default: "imperial") — Unit system. +Outputs: (none) +Config: + - series (string[], default: ["temp","feelsLike"]) — Data series to display. Options: "temp", "feelsLike", "humidity", "pressure". + - chartType (enum: line|area|column, default: "line") — Chart rendering style. + - showLegend (boolean, default: true) — Show chart legend. +Default size: 8×5 + +... +``` + +This is included in the LLM system prompt along with the dashboard spec schema (DSL-SPEC.md) and example specs (DEMO-SCRIPTS.md). + +## Auto-Generation Stretch Goal + +### Current State of Hoist Introspection + +Hoist models already declare their persistence surface: +- `@persist` decorator marks properties that persist. +- `@bindable` decorator marks properties with auto-setters. +- `persistWith` + `markPersist()` establish the persistence path. +- `getPersistableState()` / `setPersistableState()` are the serialization boundary. + +### What's Missing for Auto-Generation + +1. **Type information at runtime.** TypeScript types are erased. We'd need to either: + - Use decorators that capture type info (e.g., `@persist({type: 'string', default: 'New York'})`). + - Use a TypeScript transformer to extract type metadata at build time. + - Or accept that auto-generation reads the source, not the runtime. + +2. **Semantic metadata.** Persistence knows *what* is persisted, but not *what it means*. A property named `chartType` could be anything — the schema needs descriptions, enums, constraints that aren't in the persistence layer. + +3. **Input/output declarations.** Hoist's persistence has no concept of inter-widget IO. This is a V2 addition, so it can't be derived from existing Hoist code. + +### Viable Auto-Generation Path + +A build-time tool that: +1. Reads widget model source files. +2. Extracts `@persist` and `@bindable` decorated properties with their TypeScript types. +3. Merges with hand-maintained `meta.description`, `meta.inputs`, `meta.outputs` (the parts that can't be inferred). +4. Produces `WidgetMeta` objects. + +This could use the TypeScript compiler API to walk ASTs. The manually maintained parts would shrink to just descriptions and IO declarations — config properties would be auto-derived. + +### Recommendation + +Start with fully manual `meta` objects. They're small (10-20 lines per widget), accurate, and co-located with the code. Plan for auto-generation by keeping the `WidgetMeta` interface stable and the config property definitions simple. A future PR could add a build-time script that generates the config portion from TypeScript types, leaving descriptions and IO declarations manual. + +## Validation + +The schema enables three levels of validation: + +1. **Structural.** Does the widget state match the expected shape? Are all required config properties present? Are enum values valid? +2. **Referential.** Do all bindings reference existing widget IDs and output names? +3. **Semantic.** Are bound types compatible? Is the dependency graph acyclic? + +See DSL-SPEC.md for the full validation pipeline. diff --git a/client-app/src/examples/weatherv2/planning/WIRING-DESIGN.md b/client-app/src/examples/weatherv2/planning/WIRING-DESIGN.md new file mode 100644 index 000000000..ea70a777d --- /dev/null +++ b/client-app/src/examples/weatherv2/planning/WIRING-DESIGN.md @@ -0,0 +1,381 @@ +# Widget IO / Wiring Design + +## Overview + +V2's wiring model lets widgets communicate through typed, declarative bindings. Input widgets (e.g., City Chooser) **publish outputs**; display widgets (e.g., Forecast Chart) **consume inputs** via explicit bindings. Bindings are part of persisted state, so the wiring graph survives save/load and can be authored by an LLM. + +## Design Principles + +1. **MobX-native.** Outputs are observables. Input resolution is computed. Changes propagate reactively. +2. **Persisted.** Bindings live in each widget's `viewState` — they flow through Hoist's existing persistence pipeline without modification. +3. **LLM-friendly.** Simple JSON structure, no magic. An LLM can generate valid bindings from a schema. +4. **Validated.** Dangling references, type mismatches, and cycles are caught before hydration. +5. **Hoist-idiomatic.** Uses `@lookup`, `addReaction`, `persistWith`, `DashViewModel` — no off-reservation patterns. + +## Core Concepts + +### Outputs + +A widget **publishes** named output values. Each output has a name, a type, and a current value. + +```typescript +// CityChooserModel publishes selectedCity +this.publishOutput('selectedCity', this.selectedCity); +``` + +Outputs are registered in the `WiringModel`'s observable map. When the value changes, downstream consumers react automatically via MobX. + +### Inputs + +A widget **declares** named inputs it can consume. Each input has a name, a type, a default value (used when unbound), and an optional `required` flag. + +```typescript +// CurrentConditionsModel declares it needs a city +static meta = { + inputs: [{name: 'city', type: 'string', required: true, default: 'New York'}] +}; +``` + +### Bindings + +A **binding** connects a widget's input to a source. Bindings live in the widget's persisted `viewState` under a `bindings` key. + +Three binding types: + +| Type | JSON Shape | Meaning | +|------|-----------|---------| +| Widget output | `{"fromWidget": "city1", "output": "selectedCity"}` | Read another widget's output | +| Constant | `{"const": "New York"}` | Static value | +| Unbound | *(no binding, no manual value in viewState)* | Widget is unconfigured — needs reconfiguration | + +Note: Input defaults are seeded into `viewState` at widget-addition time. When a binding is culled (e.g. the provider widget is removed) and no manual value exists, the input is unbound and `resolveInput()` returns `undefined`. + +### Widget Instance IDs + +Every widget instance in a dashboard has a unique ID, generated by `DashCanvasModel` (format: `specId` for the first instance, `specId_2`, `specId_3`, etc.). Bindings reference these instance IDs. + +When the LLM generates a spec, it doesn't set IDs directly — the IDs are assigned by `DashCanvasModel.loadState()`. Instead, the LLM uses **positional references** or the spec assigns stable IDs via a pre-processing step (see DSL-SPEC.md). + +**Decision:** We add a pre-processing step that assigns stable `id` fields to each widget in the spec before hydration. This lets LLM-authored specs use meaningful IDs like `"city1"` in bindings. + +## WiringModel + +The `WiringModel` is the runtime coordinator. It lives on `WeatherV2DashModel` alongside `DashCanvasModel`. + +```typescript +class WiringModel extends HoistModel { + // Observable map: widgetInstanceId → {outputName → value} + @observable.ref + private _outputs = new Map>(); + + /** Publish a named output from a widget instance. */ + @action + publishOutput(widgetId: string, outputName: string, value: any) { + const widgetOutputs = this._outputs.get(widgetId) ?? new Map(); + widgetOutputs.set(outputName, value); + this._outputs.set(widgetId, new Map(widgetOutputs)); + // Replace the outer map to trigger MobX reactions + this._outputs = new Map(this._outputs); + } + + /** Resolve a binding to its current value. Returns undefined if unresolvable. */ + resolveBinding(binding: BindingSpec): any { + if (!binding) return undefined; + if ('const' in binding) return binding.const; + if ('fromWidget' in binding) { + return this._outputs.get(binding.fromWidget)?.get(binding.output); + } + return undefined; + } + + /** Unregister all outputs for a widget (called on widget removal). */ + @action + removeWidget(widgetId: string) { + this._outputs.delete(widgetId); + this._outputs = new Map(this._outputs); + } +} +``` + +### Access Pattern + +Widgets access the wiring model through the app singleton — the same pattern V1 uses for `WeatherDashModel`: + +```typescript +// In a widget model +get wiringModel(): WiringModel { + return AppModel.instance.weatherV2DashModel.wiringModel; +} +``` + +This is intentionally simple and mirrors V1's `AppModel.instance.weatherDashModel` pattern used by all existing widgets. + +## WeatherWidgetModel Base Class + +All V2 widget models extend a common base that provides wiring convenience methods: + +```typescript +abstract class WeatherWidgetModel extends HoistModel { + @lookup(() => DashViewModel) viewModel: DashViewModel; + + /** Override in subclass with widget metadata. */ + static meta: WidgetMeta; + + override onLinked() { + super.onLinked(); + this.persistWith = {dashViewModel: this.viewModel}; + } + + /** Resolve an input binding to its current value. + * Chain: binding → manual viewState value → undefined (unbound). + * Input defaults are seeded into viewState at widget-addition time, + * so there is no meta-default fallback here. */ + protected resolveInput(inputName: string): T | undefined { + const viewState = this.viewModel.viewState; + const binding = viewState?.bindings?.[inputName]; + if (binding) { + const resolved = this.wiringModel.resolveBinding(binding); + if (resolved !== undefined) return resolved; + } + const directValue = viewState?.[inputName]; + if (directValue !== undefined) return directValue; + return undefined; + } + + /** Publish an output value. */ + protected publishOutput(name: string, value: any) { + this.wiringModel.publishOutput(this.viewModel.id, name, value); + } + + private get wiringModel(): WiringModel { + return AppModel.instance.weatherV2DashModel.wiringModel; + } +} +``` + +## Persisted State Shape + +Bindings are stored in each widget's `viewState` under a `bindings` key. This is standard Hoist `DashViewProvider` persistence — no special handling needed. + +```json +{ + "state": [ + { + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "title": "City Selector", + "state": { + "selectedCity": "New York" + } + }, + { + "viewSpecId": "currentConditions", + "layout": {"x": 3, "y": 0, "w": 9, "h": 5}, + "title": "Current Weather", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"} + }, + "showFeelsLike": true, + "showHumidity": true + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 0, "y": 5, "w": 12, "h": 5}, + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line" + } + } + ] +} +``` + +## Runtime Propagation + +1. **Widget mount:** Widget model's `onLinked()` fires → calls `persistWith = {dashViewModel}` → persistence provider reads stored `viewState` (including bindings). +2. **Output registration:** Widget model's `onLinked()` sets up a reaction that publishes its output values to the `WiringModel` whenever they change. +3. **Input resolution:** Consuming widgets use `resolveInput('city')` in computed properties or reactions. Since `resolveInput` reads from `WiringModel._outputs` (an observable map), MobX tracks the dependency automatically. +4. **Change propagation:** When City Chooser changes `selectedCity` → reaction publishes to `WiringModel` → MobX invalidates all computed properties / reactions that called `resolveInput('city')` on that widget → downstream widgets re-render or reload. + +### Example: City Chooser → Forecast Chart + +``` +CityChooserModel.selectedCity changes ("New York" → "London") + ↓ (reaction in CityChooserModel.onLinked) +WiringModel.publishOutput("cityChooser", "selectedCity", "London") + ↓ (MobX observable change) +ForecastChartModel.city (computed via resolveInput) invalidated + ↓ (reaction tracking this.city) +ForecastChartModel.loadAsync() → fetches London forecast + ↓ +Chart re-renders with London data +``` + +## Validation Rules + +### Static Validation (at spec load / JSON harness apply time) + +1. **Dangling widget references.** Every `fromWidget` in a binding must reference a widget instance ID that exists in the spec. +2. **Dangling output references.** Every `output` in a binding must be a declared output of the referenced widget's type. +3. **Type compatibility.** The declared type of the output must be assignable to the declared type of the input. (For V1: simple string equality. Stretch: subtype checking.) +4. **No cycles.** The binding graph must be a DAG. Detect via topological sort — if it fails, there's a cycle. +5. **Required inputs.** Inputs marked `required: true` should have a binding (warning, not hard error — defaults provide fallback). + +### Runtime Validation + +1. **Missing output value.** If a widget hasn't published its output yet (e.g., still loading), downstream inputs resolve to `undefined` → widget uses its `??` fallback. +2. **Widget removal.** When a widget is removed from the canvas, `WeatherV2DashModel` automatically: (a) calls `WiringModel.removeWidget()` to clear its outputs, and (b) scans all remaining widgets' `viewState.bindings` to cull any entries referencing the removed widget. Culled inputs become unbound (`resolveInput` returns `undefined`), and the settings form shows them as "Not configured" with a warning indicator. + +## Cycle Prevention + +The binding graph is a DAG by construction: each binding points *from* one widget *to* another widget's output. Cycles would mean A's input depends on B's output, and B's input depends on A's output (directly or transitively). + +**Detection:** Before hydrating a spec, build a directed graph of widget dependencies from bindings. Run a topological sort. If it fails, reject the spec with an error identifying the cycle. + +**Runtime:** Cycles can't form at runtime because bindings are static (persisted) — they only change when a new spec is applied. No need for runtime cycle detection. + +## Type System + +Keep it simple. V1 types: + +| Type | Values | Used by | +|------|--------|---------| +| `string` | City names, text | City Chooser output, Markdown config | +| `units` | `'imperial' \| 'metric'` | Units Toggle output | +| `number` | Numeric values | Future extensibility | +| `boolean` | true/false | Future extensibility | + +Type checking is string-based equality for V1. The output's declared type must match the input's declared type exactly. This is intentionally restrictive — better to be strict and relax later than to be loose and break things. + +## Binding Spec Types + +```typescript +type BindingSpec = + | {fromWidget: string; output: string} // Reference to another widget's output + | {const: any}; // Literal constant value + +interface InputDef { + name: string; // Input name (e.g., 'city') + type: string; // Expected type (e.g., 'string') + required?: boolean; // Whether binding is required (default false) + default?: any; // Value when unbound (default undefined) + description?: string; // Human/LLM-readable description +} + +interface OutputDef { + name: string; // Output name (e.g., 'selectedCity') + type: string; // Value type (e.g., 'string') + description?: string; // Human/LLM-readable description +} +``` + +## Full Example: Multi-City Comparison Dashboard + +This example demonstrates the wiring model's compositional power — two city choosers driving independent sets of display widgets: + +```json +{ + "state": [ + { + "viewSpecId": "cityChooser", + "layout": {"x": 0, "y": 0, "w": 3, "h": 2}, + "title": "City A", + "state": {"selectedCity": "New York"} + }, + { + "viewSpecId": "cityChooser", + "layout": {"x": 6, "y": 0, "w": 3, "h": 2}, + "title": "City B", + "state": {"selectedCity": "London"} + }, + { + "viewSpecId": "unitsToggle", + "layout": {"x": 9, "y": 0, "w": 3, "h": 2}, + "state": {"units": "imperial"} + }, + { + "viewSpecId": "currentConditions", + "layout": {"x": 0, "y": 2, "w": 6, "h": 4}, + "title": "New York Conditions", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + } + } + }, + { + "viewSpecId": "currentConditions", + "layout": {"x": 6, "y": 2, "w": 6, "h": 4}, + "title": "London Conditions", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser_2", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + } + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 0, "y": 6, "w": 6, "h": 5}, + "title": "New York Forecast", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line" + } + }, + { + "viewSpecId": "forecastChart", + "layout": {"x": 6, "y": 6, "w": 6, "h": 5}, + "title": "London Forecast", + "state": { + "bindings": { + "city": {"fromWidget": "cityChooser_2", "output": "selectedCity"}, + "units": {"fromWidget": "unitsToggle", "output": "units"} + }, + "series": ["temp", "feelsLike"], + "chartType": "line" + } + } + ] +} +``` + +In this dashboard: +- Two City Chooser instances (`cityChooser` and `cityChooser_2`) each emit `selectedCity`. +- One Units Toggle (`unitsToggle`) emits `units` shared by all display widgets. +- Two Current Conditions and two Forecast Charts, each bound to a different city chooser but the same units toggle. +- Changing "City A" updates the left column; changing "City B" updates the right column. Changing units updates everything. + +## Stretch: Dashboard Variables + +A future enhancement could add dashboard-level variables — named values that any widget can bind to, managed by the dashboard model rather than emitted by a widget: + +```json +{ + "variables": { + "defaultCity": {"type": "string", "value": "New York"} + }, + "state": [ + { + "viewSpecId": "currentConditions", + "state": { + "bindings": { + "city": {"fromVariable": "defaultCity"} + } + } + } + ] +} +``` + +This is not in V1 scope but the `BindingSpec` type can be extended trivially. Noted for future consideration. diff --git a/client-app/src/examples/weatherv2/svc/LlmChatService.ts b/client-app/src/examples/weatherv2/svc/LlmChatService.ts new file mode 100644 index 000000000..5fbd71b61 --- /dev/null +++ b/client-app/src/examples/weatherv2/svc/LlmChatService.ts @@ -0,0 +1,266 @@ +import {HoistService, XH} from '@xh/hoist/core'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {DashSpec} from '../dash/types'; +import {computeInstanceIds} from '../dash/validation'; +import {CITIES} from '../widgets/CityChooserWidget'; +import {ToolDefinition} from './LlmToolService'; + +/** A content block in an Anthropic API message. */ +export interface ContentBlock { + type: 'text' | 'tool_use' | 'tool_result'; + // text block + text?: string; + // tool_use block + id?: string; + name?: string; + input?: Record; + // tool_result block + tool_use_id?: string; + content?: string; + is_error?: boolean; +} + +/** + * A message in the conversation history. + * User messages use a simple string content; assistant messages may contain + * rich content blocks (text + tool_use) when tool calling is active. + */ +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string | ContentBlock[]; +} + +/** + * Service for assembling LLM prompts, calling the server proxy, + * and parsing spec responses. + */ +export class LlmChatService extends HoistService { + static instance: LlmChatService; + + /** Build the system prompt including widget schemas, spec format, and rules. */ + buildSystemPrompt(currentSpec?: DashSpec): string { + const widgetDocs = widgetRegistry.generateLLMPrompt(); + const cityList = CITIES.join(', '); + const parts: string[] = [ + SYSTEM_INTRO, + '## Widget Types\n\n' + widgetDocs, + SPEC_FORMAT_DOCS, + WIRING_RULES, + LAYOUT_RULES, + CITY_RULES.replace('{{CITIES}}', cityList) + ]; + + if (currentSpec?.state?.length) { + const ids = computeInstanceIds(currentSpec.state); + const annotated = currentSpec.state.map((widget, idx) => ({ + instanceId: ids[idx], + ...widget + })); + parts.push( + '## Current Dashboard State\n\n' + + `The dashboard currently has ${annotated.length} widget(s). ` + + 'Each widget is shown with its `instanceId` — use these IDs with ' + + 'update_widget, remove_widget, and move_widget tools.\n\n```json\n' + + JSON.stringify(annotated, null, 2) + + '\n```' + ); + } + + parts.push(TOOL_USE_GUIDANCE); + parts.push(OUTPUT_RULES); + return parts.join('\n\n'); + } + + /** + * Call the LLM proxy endpoint and return the full response. + * Returns the raw content block array (which may contain text + tool_use blocks), + * the stop reason, and the full API response. + */ + async generateAsync( + systemPrompt: string, + messages: ChatMessage[], + tools?: ToolDefinition[] + ): Promise<{content: ContentBlock[]; stopReason: string; raw: any}> { + const body: any = {systemPrompt, messages}; + if (tools?.length) body.tools = tools; + + const response = await XH.postJson({ + url: 'llm/generate', + body, + timeout: {interval: 120_000, message: 'LLM request timed out.'}, + track: { + category: 'WeatherV2', + message: 'LLM generate', + severity: 'DEBUG', + data: {messageCount: messages.length}, + logData: true + } + }); + + const content: ContentBlock[] = response?.content ?? []; + const stopReason: string = response?.stop_reason ?? 'end_turn'; + return {content, stopReason, raw: response}; + } + + /** + * Extract a JSON dashboard spec from the LLM's text response. + * Looks for the first ```json code block, or falls back to raw JSON. + */ + parseSpecFromResponse(text: string): DashSpec | null { + // Try to find a fenced JSON block + const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/); + if (fenceMatch) { + try { + return JSON.parse(fenceMatch[1].trim()) as DashSpec; + } catch { + // Fall through + } + } + + // Try to parse the entire response as JSON + try { + return JSON.parse(text.trim()) as DashSpec; + } catch { + // Fall through + } + + // Try to find a JSON object in the response + const braceMatch = text.match(/\{[\s\S]*\}/); + if (braceMatch) { + try { + return JSON.parse(braceMatch[0]) as DashSpec; + } catch { + // Give up + } + } + + return null; + } +} + +//-------------------------------------------------- +// System prompt sections +//-------------------------------------------------- +const SYSTEM_INTRO = `You are a friendly dashboard assistant for Weather Dashboard V2. Your job is to build and modify weather dashboards based on what the user asks for. + +Your audience is **business users, not software developers**. In your conversational responses: +- Use plain, approachable language. Talk about what the dashboard will *show* and *do*, not how it works internally. +- **Never mention** technical terms like "bindings", "wiring", "instance IDs", "specs", "JSON", "viewSpecId", "state arrays", or grid coordinates unless the user explicitly asks a technical or developer-oriented question. +- Instead of "I'll wire the forecast chart's city input to the city chooser via a binding", say something like "I'll connect the forecast chart to your city selector so it updates when you pick a different city." +- Keep responses concise and helpful — a sentence or two explaining what you're setting up is plenty. +- If the user asks a technical or developer question (e.g. about the JSON format, how bindings work, the spec structure), then and only then should you provide technical details. + +The dashboard uses a 12-column grid layout with configurable widgets that can be connected together so they share data (e.g. a city selector driving all the charts).`; + +const SPEC_FORMAT_DOCS = `## Dashboard Spec Format + +The spec is a JSON object with this structure: +\`\`\`json +{ + "version": 1, + "state": [ + { + "viewSpecId": "widgetTypeId", + "layout": {"x": 0, "y": 0, "w": 6, "h": 5}, + "state": { + "bindings": { + "inputName": {"fromWidget": "sourceInstanceId", "output": "outputName"} + }, + "configKey": "configValue" + } + } + ] +} +\`\`\` + +Each widget in the \`state\` array has: +- \`viewSpecId\` (required): Widget type from the catalog. +- \`layout\` (required): Grid position. \`x\` is column (0-11), \`y\` is row, \`w\` is width (1-12), \`h\` is height. +- \`state\` (optional): Widget config and input bindings. + +**Widget Titles:** Display widgets (currentConditions, forecastChart, precipChart, windChart, summaryGrid) auto-generate their titles from their bound city (e.g. "Forecast — Tokyo"). Do NOT set a top-level \`title\` for these widgets — it will be overwritten. Input widgets (cityChooser, unitsToggle) also receive automatic static titles ("City", "Units") — do NOT set titles for these either. For the \`markdownContent\` widget, set the title via \`state.title\` (e.g. \`"state": {"title": "Dashboard Header", "content": "..."}\`). The dashInspector uses its default title.`; + +const WIRING_RULES = `## Wiring Rules + +Widgets communicate via bindings. An input widget publishes outputs; display widgets consume them via bindings. + +**Instance ID assignment:** Widget instance IDs are assigned by order of appearance in the \`state\` array using 0-indexed suffixes: first instance of type X gets ID "X_0", second gets "X_1", third "X_2", etc. Use these IDs in binding references. + +**Binding format:** +- Wire to another widget: \`{"fromWidget": "instanceId", "output": "outputName"}\` +- Constant value: \`{"const": "value"}\` + +**IMPORTANT: Every display widget input MUST be set via a binding** — either a \`fromWidget\` binding or a \`const\` binding. Do NOT set input values as direct state keys. For example, to set a widget's city to "Tokyo", use \`"bindings": {"city": {"const": "Tokyo"}}\`, NOT \`"city": "Tokyo"\` at the state level. + +**Common wiring patterns:** +- CityChooser publishes \`selectedCity\` → display widgets bind their \`city\` input to it, e.g. \`{"fromWidget": "cityChooser_0", "output": "selectedCity"}\`. +- UnitsToggle publishes \`units\` → display widgets bind their \`units\` input to it, e.g. \`{"fromWidget": "unitsToggle_0", "output": "units"}\`. +- Fixed city (no chooser widget): use a const binding, e.g. \`"bindings": {"city": {"const": "Tokyo"}}\`. +- Fixed units (no toggle widget): use a const binding, e.g. \`"bindings": {"units": {"const": "metric"}}\`.`; + +const LAYOUT_RULES = `## Layout Rules + +- The grid has **12 columns** and each row is **30px** tall. Widgets cannot extend past column 12 (x + w <= 12). +- Rows are unlimited — widgets can stack vertically. +- Minimum widget size varies by type (see widget docs). +- Widgets should not overlap. Place them so x ranges don't overlap within the same y range. + +**Sizing guidelines (row height = 30px):** +- Input widgets (cityChooser, unitsToggle): h=3 (90px) — just enough for the frame + control. **Always use h=3 for these** unless the user specifically requests a larger size. Making them taller wastes vertical space. Check each widget's "Ideal size" annotation and prefer it. +- Display widgets (charts, grids, conditions): h=8 (240px) is a good default. Use h=6 for compact or h=10+ for emphasis. +- Markdown banners: h=3 to h=5 depending on content length. + +**Layout best practices:** +- Strive for balanced, symmetrical arrangements. Avoid leaving large empty gaps between widgets. +- Align widgets on common row boundaries where possible for a clean grid appearance. +- Place input controls (city chooser, units toggle) near the top for easy access. +- When comparing multiple cities, arrange them in evenly-spaced columns (e.g. 3 cities at w=4 each, or 2 cities at w=6 each).`; + +const CITY_RULES = `## Available Cities + +The city chooser dropdown includes these curated cities: {{CITIES}}. + +However, the weather API accepts **any valid city name worldwide** — the dropdown also allows users to type in custom cities. When the user asks about a city not in the list, you can set it directly via \`state.selectedCity\` on the cityChooser widget. Use the standard English name for the city (e.g. "Munich" not "München", "Rome" not "Roma"). + +If a user asks "what cities are available", mention both the curated list and the ability to enter any city name. If asked for a city you're unsure about, use it anyway — the weather API will handle it.`; + +const TOOL_USE_GUIDANCE = `## Tools + +You have tools for **all** dashboard and app operations. You MUST use tools to make changes — do NOT output raw JSON specs in your text response. Only tool_use API calls actually execute actions. + +**Dashboard tools** (use these for all layout and widget changes): +- \`set_dashboard\` — Replace the entire dashboard with a complete spec. **This is the primary tool for dashboard changes.** +- \`update_widget\` — Update a single widget's config or bindings (state changes merge). Best for targeted tweaks. +- \`add_widget\` — Add one widget. Returns the new instance ID. +- \`remove_widget\` — Remove a widget by instance ID. +- \`move_widget\` — Reposition or resize a single widget. +- \`list_widgets\` — Query the current dashboard inventory. + +**CRITICAL — Choosing the right tool:** +Use \`set_dashboard\` for ANY request that involves: +- Building a new dashboard from scratch +- Adding or removing multiple widgets +- Rearranging the layout (repositioning widgets) +- Any combination of adding widgets + changing bindings + adjusting layout +- Structural changes that affect more than 2-3 widgets + +Use \`update_widget\` ONLY for small, isolated changes to 1-3 existing widgets where the layout stays the same: +- Changing a chart type on one widget +- Switching a binding source +- Toggling a config flag + +**Why this matters:** Each granular tool call (add_widget, move_widget, update_widget) applies immediately and rebuilds the dashboard. Chaining many of them creates a poor experience — multiple redraws, potential layout instability, and harder-to-debug results. A single \`set_dashboard\` call produces the final state atomically and reliably. + +**Rule of thumb:** If your plan involves more than 3 tool calls, use \`set_dashboard\` instead with the complete desired layout. + +**App operation tools:** save_dashboard_as_view, switch_to_view, reset_dashboard, toggle_theme, open_widget_chooser, show_json_spec, toggle_manual_editing. + +After tool calls, briefly confirm the result in your text response.`; + +const OUTPUT_RULES = `## Output Rules + +**Do NOT output raw JSON specs or code fences in your text response.** All dashboard changes must be made through tool calls (set_dashboard, add_widget, remove_widget, update_widget, move_widget). + +Your text response should be a brief, friendly explanation of what you're setting up or changing — written for a business audience, not a technical one. Focus on what the user will *see* in the dashboard, not how it's implemented. + +If the user asks a general question or something conversational that does not require a dashboard change, respond naturally without any tool calls. Not every message needs a dashboard update.`; diff --git a/client-app/src/examples/weatherv2/svc/LlmToolService.ts b/client-app/src/examples/weatherv2/svc/LlmToolService.ts new file mode 100644 index 000000000..65cb2c67a --- /dev/null +++ b/client-app/src/examples/weatherv2/svc/LlmToolService.ts @@ -0,0 +1,614 @@ +import {HoistService, PersistableState, XH} from '@xh/hoist/core'; +import {AppModel} from '../AppModel'; +import {DashSpec, DashWidgetState} from '../dash/types'; +import {validateSpec, migrateSpec, computeInstanceIds} from '../dash/validation'; +import {widgetRegistry} from '../dash/WidgetRegistry'; + +export interface ToolDefinition { + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +/** + * Service that defines and executes LLM tools (function calling). + * + * Tools are client-side operations that manipulate app and dashboard state. + * The LLM chooses when to call them based on the user's natural language requests. + * + * Dashboard tools (set_dashboard, add_widget, remove_widget, update_widget, + * move_widget, list_widgets) provide structured, validated dashboard manipulation. + * App tools (save/load views, toggle theme, etc.) handle non-dashboard operations. + */ +export class LlmToolService extends HoistService { + static instance: LlmToolService; + + /** Return Anthropic-format tool definitions for the API request. */ + getToolDefinitions(): ToolDefinition[] { + return TOOL_DEFINITIONS; + } + + /** Execute a tool call and return a result string. */ + async executeToolAsync( + name: string, + input: Record + ): Promise<{content: string; isError: boolean}> { + try { + const result = await this.doExecuteAsync(name, input); + return {content: result, isError: false}; + } catch (e) { + return {content: e.message || `Tool "${name}" failed.`, isError: true}; + } + } + + //------------------ + // Implementation + //------------------ + private async doExecuteAsync(name: string, input: Record): Promise { + const appModel = AppModel.instance, + viewManager = appModel.weatherViewManager; + + switch (name) { + //---------------------------------------------- + // Dashboard manipulation tools + //---------------------------------------------- + case 'set_dashboard': + return this.doSetDashboard(input); + + case 'add_widget': + return this.doAddWidget(input); + + case 'remove_widget': + return this.doRemoveWidget(input); + + case 'update_widget': + return this.doUpdateWidget(input); + + case 'move_widget': + return this.doMoveWidget(input); + + case 'list_widgets': + return this.doListWidgets(); + + //---------------------------------------------- + // App operation tools + //---------------------------------------------- + case 'save_dashboard_as_view': { + const viewName = input.name as string; + if (!viewName?.trim()) throw new Error('View name is required.'); + await viewManager.saveAsAsync({ + name: viewName.trim(), + group: null, + description: '', + isShared: false, + isGlobal: false, + value: viewManager.getValue() + }); + return `View "${viewName.trim()}" saved successfully.`; + } + + case 'switch_to_view': { + const viewName = input.name as string; + if (!viewName?.trim()) throw new Error('View name is required.'); + const target = viewManager.views.find( + v => v.name.toLowerCase() === viewName.trim().toLowerCase() + ); + if (!target) { + const available = viewManager.views.map(v => v.name).join(', '); + throw new Error( + `No view found matching "${viewName}". Available views: ${available || 'none'}` + ); + } + await viewManager.selectViewAsync(target, {alertUnsavedChanges: false}); + return `Switched to view "${target.name}".`; + } + + case 'reset_dashboard': { + await viewManager.resetAsync(); + return "Dashboard reset to the current view's saved state."; + } + + case 'toggle_theme': { + XH.toggleTheme(); + const newTheme = XH.darkTheme ? 'dark' : 'light'; + return `Theme toggled to ${newTheme} mode.`; + } + + case 'open_widget_chooser': { + appModel.showWidgetChooser = true; + return 'Widget chooser panel opened.'; + } + + case 'show_json_spec': { + appModel.showJsonHarness = true; + return 'JSON spec editor opened.'; + } + + case 'toggle_manual_editing': { + const newVal = !appModel.manualEditingEnabled; + appModel.manualEditingEnabled = newVal; + return `Manual editing ${newVal ? 'enabled' : 'disabled'}.`; + } + + default: + throw new Error(`Unknown tool: "${name}"`); + } + } + + //---------------------------------------------- + // Dashboard tool implementations + //---------------------------------------------- + + /** Replace the entire dashboard with a new spec. */ + private doSetDashboard(input: Record): string { + const rawSpec = input.spec as DashSpec; + if (!rawSpec || !Array.isArray(rawSpec.state)) { + throw new Error('spec must be an object with a "state" array.'); + } + + const spec = migrateSpec({version: rawSpec.version ?? 1, state: rawSpec.state}); + const result = validateSpec(spec); + if (!result.valid) { + const errorSummary = result.errors.map(e => e.message).join('; '); + throw new Error(`Validation failed: ${errorSummary}`); + } + + this.applyState(spec.state); + + const ids = computeInstanceIds(spec.state); + const widgetSummary = ids.join(', '); + return `Dashboard updated with ${spec.state.length} widgets: ${widgetSummary}.`; + } + + /** Add a single widget to the dashboard. */ + private doAddWidget(input: Record): string { + const {viewSpecId, layout, state: widgetState} = input; + + if (!viewSpecId || !widgetRegistry.has(viewSpecId)) { + const available = widgetRegistry.getIds().join(', '); + throw new Error(`Invalid viewSpecId "${viewSpecId}". Available types: ${available}.`); + } + + const currentState = this.getCurrentStateArray(); + + // Build the new widget entry + const newWidget: DashWidgetState = { + viewSpecId, + layout: layout ?? this.getAutoLayout(viewSpecId) + }; + if (widgetState) newWidget.state = widgetState as Record; + + const newState = [...currentState, newWidget]; + + // Validate the resulting full spec + const spec: DashSpec = {version: 1, state: newState}; + const result = validateSpec(spec); + if (!result.valid) { + const errorSummary = result.errors.map(e => e.message).join('; '); + throw new Error(`Cannot add widget — validation failed: ${errorSummary}`); + } + + this.applyState(newState); + + const ids = computeInstanceIds(newState); + const newId = ids[ids.length - 1]; + return `Added ${viewSpecId} widget as "${newId}".`; + } + + /** Remove a widget by instance ID. */ + private doRemoveWidget(input: Record): string { + const {instanceId} = input; + if (!instanceId) throw new Error('instanceId is required.'); + + const currentState = this.getCurrentStateArray(); + const ids = computeInstanceIds(currentState); + const idx = ids.indexOf(instanceId); + + if (idx === -1) { + throw new Error( + `No widget found with instanceId "${instanceId}". ` + + `Current widgets: ${ids.join(', ')}.` + ); + } + + const newState = [...currentState.slice(0, idx), ...currentState.slice(idx + 1)]; + + // Validate (removal can break bindings referencing the removed widget) + const spec: DashSpec = {version: 1, state: newState}; + const result = validateSpec(spec); + if (!result.valid) { + const errorSummary = result.errors.map(e => e.message).join('; '); + throw new Error( + `Cannot remove "${instanceId}" — validation failed: ${errorSummary}. ` + + 'Other widgets may have bindings referencing this widget. ' + + 'Update or remove those bindings first.' + ); + } + + this.applyState(newState); + return `Removed widget "${instanceId}". Dashboard now has ${newState.length} widgets.`; + } + + /** Update a widget's state (config, bindings) by instance ID. */ + private doUpdateWidget(input: Record): string { + const {instanceId, state: stateChanges, layout: layoutChanges} = input; + if (!instanceId) throw new Error('instanceId is required.'); + if (!stateChanges && !layoutChanges) { + throw new Error('At least one of "state" or "layout" must be provided.'); + } + + const currentState = this.getCurrentStateArray(); + const ids = computeInstanceIds(currentState); + const idx = ids.indexOf(instanceId); + + if (idx === -1) { + throw new Error( + `No widget found with instanceId "${instanceId}". ` + + `Current widgets: ${ids.join(', ')}.` + ); + } + + const widget = {...currentState[idx]}; + + // Merge state changes (shallow merge, with special handling for bindings) + if (stateChanges) { + const existingState = {...(widget.state ?? {})}; + for (const [key, value] of Object.entries(stateChanges)) { + if (key === 'bindings') { + // Merge bindings: new bindings override existing ones by input name + existingState.bindings = { + ...(existingState.bindings ?? {}), + ...(value as Record) + }; + } else { + existingState[key] = value; + } + } + widget.state = existingState; + } + + // Merge layout changes + if (layoutChanges) { + widget.layout = {...widget.layout, ...layoutChanges}; + } + + const newState = [...currentState]; + newState[idx] = widget; + + const spec: DashSpec = {version: 1, state: newState}; + const result = validateSpec(spec); + if (!result.valid) { + const errorSummary = result.errors.map(e => e.message).join('; '); + throw new Error(`Cannot update "${instanceId}" — validation failed: ${errorSummary}`); + } + + this.applyState(newState); + return `Updated widget "${instanceId}".`; + } + + /** Move/resize a widget by instance ID. */ + private doMoveWidget(input: Record): string { + const {instanceId, layout} = input; + if (!instanceId) throw new Error('instanceId is required.'); + if (!layout) throw new Error('layout is required.'); + + // Delegate to update_widget with layout-only changes + return this.doUpdateWidget({instanceId, layout}); + } + + /** List current widgets with instance IDs, types, layouts, and state. */ + private doListWidgets(): string { + const currentState = this.getCurrentStateArray(); + const ids = computeInstanceIds(currentState); + + const widgets = currentState.map((widget, idx) => ({ + instanceId: ids[idx], + viewSpecId: widget.viewSpecId, + layout: widget.layout, + state: widget.state ?? {} + })); + + return JSON.stringify(widgets, null, 2); + } + + //---------------------------------------------- + // Helpers + //---------------------------------------------- + + /** Get the current dashboard state array from DashCanvasModel. */ + private getCurrentStateArray(): DashWidgetState[] { + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + const persistable = dashModel.getPersistableState(); + return persistable?.value?.state ?? []; + } + + /** Validate and apply a state array to the dashboard. */ + private applyState(state: DashWidgetState[]) { + const dashModel = AppModel.instance.weatherV2DashModel.dashCanvasModel; + dashModel.setPersistableState(new PersistableState({state})); + } + + /** Compute a reasonable auto-placement layout for a new widget. */ + private getAutoLayout(viewSpecId: string): {x: number; y: number; w: number; h: number} { + const meta = widgetRegistry.get(viewSpecId); + const w = meta?.defaultSize?.w ?? 6; + const h = meta?.defaultSize?.h ?? 6; + + // Place at the bottom of the current dashboard + const currentState = this.getCurrentStateArray(); + let maxY = 0; + for (const widget of currentState) { + const bottom = (widget.layout?.y ?? 0) + (widget.layout?.h ?? 0); + if (bottom > maxY) maxY = bottom; + } + + return {x: 0, y: maxY, w, h}; + } +} + +//-------------------------------------------------- +// Tool definitions in Anthropic API format +//-------------------------------------------------- +const TOOL_DEFINITIONS: ToolDefinition[] = [ + //---------------------------------------------- + // Dashboard manipulation tools + //---------------------------------------------- + { + name: 'set_dashboard', + description: + 'Replace the entire dashboard with a complete new spec. Use this when creating a ' + + 'dashboard from scratch or making large-scale restructuring changes that affect most ' + + 'widgets. The spec is validated before being applied. For smaller, targeted changes, ' + + 'prefer add_widget, remove_widget, or update_widget.', + input_schema: { + type: 'object', + properties: { + spec: { + type: 'object', + description: + 'Complete dashboard spec with "version" (number, typically 1) and ' + + '"state" (array of widget objects). Each widget has: viewSpecId (string), ' + + 'layout ({x, y, w, h}), and optional state ({bindings, ...config}).', + properties: { + version: {type: 'number'}, + state: { + type: 'array', + items: { + type: 'object', + properties: { + viewSpecId: {type: 'string'}, + layout: { + type: 'object', + properties: { + x: {type: 'number'}, + y: {type: 'number'}, + w: {type: 'number'}, + h: {type: 'number'} + }, + required: ['x', 'y', 'w', 'h'] + }, + state: {type: 'object'} + }, + required: ['viewSpecId', 'layout'] + } + } + }, + required: ['state'] + } + }, + required: ['spec'] + } + }, + { + name: 'add_widget', + description: + 'Add a single widget to the dashboard. The widget is appended and assigned the ' + + 'next available instance ID for its type. Returns the new instance ID. If layout ' + + 'is omitted, the widget is auto-placed at the bottom of the dashboard.', + input_schema: { + type: 'object', + properties: { + viewSpecId: { + type: 'string', + description: 'Widget type to add (e.g. "forecastChart", "cityChooser").' + }, + layout: { + type: 'object', + description: 'Grid position and size. Omit to auto-place at the bottom.', + properties: { + x: {type: 'number', description: 'Column (0-11)'}, + y: {type: 'number', description: 'Row'}, + w: {type: 'number', description: 'Width in columns (1-12)'}, + h: {type: 'number', description: 'Height in rows'} + }, + required: ['x', 'y', 'w', 'h'] + }, + state: { + type: 'object', + description: + 'Optional initial state with config keys and bindings. Example: ' + + '{"bindings": {"city": {"fromWidget": "cityChooser_0", "output": "selectedCity"}}, ' + + '"series": ["temp", "humidity"]}' + } + }, + required: ['viewSpecId'] + } + }, + { + name: 'remove_widget', + description: + 'Remove a widget from the dashboard by its instance ID. Will fail if other widgets ' + + 'have bindings referencing this widget — update those bindings first.', + input_schema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'Instance ID of the widget to remove (e.g. "forecastChart_0").' + } + }, + required: ['instanceId'] + } + }, + { + name: 'update_widget', + description: + "Update a widget's configuration, bindings, and/or layout. State changes are " + + 'merged with existing state — only the keys you provide are updated. Bindings are ' + + 'also merged by input name. Use this for targeted changes like switching a chart type, ' + + 'changing a binding, or adjusting position.', + input_schema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'Instance ID of the widget to update (e.g. "forecastChart_0").' + }, + state: { + type: 'object', + description: + 'State changes to merge. Include "bindings" to update wiring, or config ' + + 'keys to change settings. Example: {"chartType": "bar"} or ' + + '{"bindings": {"city": {"const": "Tokyo"}}}.' + }, + layout: { + type: 'object', + description: + 'Layout changes to merge. Only include the dimensions you want to change.', + properties: { + x: {type: 'number'}, + y: {type: 'number'}, + w: {type: 'number'}, + h: {type: 'number'} + } + } + }, + required: ['instanceId'] + } + }, + { + name: 'move_widget', + description: + 'Move or resize a widget by updating its layout position. Shorthand for ' + + 'update_widget with only layout changes.', + input_schema: { + type: 'object', + properties: { + instanceId: { + type: 'string', + description: 'Instance ID of the widget to move (e.g. "forecastChart_0").' + }, + layout: { + type: 'object', + description: 'New layout. Include only the dimensions to change.', + properties: { + x: {type: 'number', description: 'Column (0-11)'}, + y: {type: 'number', description: 'Row'}, + w: {type: 'number', description: 'Width in columns (1-12)'}, + h: {type: 'number', description: 'Height in rows'} + } + } + }, + required: ['instanceId', 'layout'] + } + }, + { + name: 'list_widgets', + description: + "Get the current dashboard widget inventory. Returns each widget's instance ID, " + + 'type, layout, and state (including bindings). Use this to inspect the current ' + + 'dashboard before making targeted changes.', + input_schema: { + type: 'object', + properties: {}, + required: [] + } + }, + + //---------------------------------------------- + // App operation tools + //---------------------------------------------- + { + name: 'save_dashboard_as_view', + description: + 'Save the current dashboard layout as a new named view. The current dashboard state will be captured automatically.', + input_schema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'The name for the saved view (e.g. "Tokyo Overview")' + } + }, + required: ['name'] + } + }, + { + name: 'switch_to_view', + description: + "Switch to an existing saved view by name. The dashboard will update to show that view's layout.", + input_schema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'The name of the saved view to switch to' + } + }, + required: ['name'] + } + }, + { + name: 'reset_dashboard', + description: + "Reset the dashboard to the current view's last saved state, discarding any unsaved changes.", + input_schema: { + type: 'object', + properties: {}, + required: [] + } + }, + { + name: 'toggle_theme', + description: 'Toggle between light and dark theme for the entire application.', + input_schema: { + type: 'object', + properties: {}, + required: [] + } + }, + { + name: 'open_widget_chooser', + description: + 'Open the widget chooser panel so the user can manually browse and add widgets to the dashboard.', + input_schema: { + type: 'object', + properties: {}, + required: [] + } + }, + { + name: 'show_json_spec', + description: + 'Open the JSON spec editor panel so the user can view or manually edit the raw dashboard specification.', + input_schema: { + type: 'object', + properties: {}, + required: [] + } + }, + { + name: 'toggle_manual_editing', + description: + "Toggle manual editing mode on or off. When enabled, the user can drag, resize, and add widgets directly. Useful for previewing exact layout sizing, and especially helpful whenever a user asks to hide a widget's title bar — enabling manual editing lets them see and adjust the resulting layout.", + input_schema: { + type: 'object', + properties: {}, + required: [] + } + } +]; diff --git a/client-app/src/examples/weatherv2/svc/WeatherDataService.ts b/client-app/src/examples/weatherv2/svc/WeatherDataService.ts new file mode 100644 index 000000000..590c28539 --- /dev/null +++ b/client-app/src/examples/weatherv2/svc/WeatherDataService.ts @@ -0,0 +1,128 @@ +import {HoistService, LoadSpec, XH} from '@xh/hoist/core'; +import {action, makeObservable, observable} from '@xh/hoist/mobx'; +import { + WeatherData, + NormalizedCurrent, + NormalizedForecastEntry, + CurrentWeatherResponse, + ForecastResponse +} from '../Types'; + +/** Client-side staleness threshold — 5 minutes. */ +const STALE_MS = 5 * 60 * 1000; + +/** + * Shared data provider for V2 weather widgets. + * + * Maintains a per-city cache of normalized weather data. Widgets call + * `ensureDataAsync(city)` to trigger a fetch (if not cached or stale), + * then read the observable cache reactively. + */ +export class WeatherDataService extends HoistService { + static instance: WeatherDataService; + + /** Observable cache: city → WeatherData. Replace the map reference to trigger reactions. */ + @observable.ref + private _cache = new Map(); + + constructor() { + super(); + makeObservable(this); + } + + /** Get cached data for a city (synchronous, may return null if not yet loaded). */ + getData(city: string): WeatherData | null { + return this._cache.get(city) ?? null; + } + + /** + * Ensure data is loaded for a city. Fetches from server if not cached or stale. + * Returns the cached data (which is also observable via `getData`). + */ + async ensureDataAsync(city: string, loadSpec?: LoadSpec): Promise { + const cached = this._cache.get(city); + if (cached && !this.isStale(cached)) return cached; + + const [currentRaw, forecastRaw] = await Promise.all([ + XH.fetchJson({url: 'weather/current', params: {city}, loadSpec}), + XH.fetchJson({url: 'weather/forecast', params: {city}, loadSpec}) + ]); + + if (loadSpec?.isStale) return cached ?? this.emptyData(city); + + const data = this.normalize(city, currentRaw, forecastRaw); + this.updateCache(city, data); + return data; + } + + @action + private updateCache(city: string, data: WeatherData) { + const next = new Map(this._cache); + next.set(city, data); + this._cache = next; + } + + private isStale(data: WeatherData): boolean { + return Date.now() - data.fetchedAt > STALE_MS; + } + + private normalize( + city: string, + current: CurrentWeatherResponse, + forecast: ForecastResponse + ): WeatherData { + const normalizedCurrent: NormalizedCurrent = { + temp: current.main.temp, + feelsLike: current.main.feels_like, + humidity: current.main.humidity, + pressure: current.main.pressure, + windSpeed: current.wind.speed, + windGust: current.wind.gust, + conditions: current.weather?.[0]?.main ?? 'Unknown', + description: current.weather?.[0]?.description ?? '', + iconCode: current.weather?.[0]?.icon ?? '01d' + }; + + const normalizedForecast: NormalizedForecastEntry[] = (forecast.list ?? []).map(item => ({ + dt: item.dt * 1000, + temp: item.main.temp, + feelsLike: item.main.feels_like, + tempMin: item.main.temp_min, + tempMax: item.main.temp_max, + humidity: item.main.humidity, + pressure: item.main.pressure, + windSpeed: item.wind.speed, + windGust: item.wind.gust, + precipProbability: Math.round(item.pop * 100), + precipVolume: item.rain?.['3h'] ?? 0, + conditions: item.weather?.[0]?.main ?? 'Unknown', + description: item.weather?.[0]?.description ?? '', + iconCode: item.weather?.[0]?.icon ?? '01d' + })); + + return { + city, + current: normalizedCurrent, + forecast: normalizedForecast, + fetchedAt: Date.now() + }; + } + + private emptyData(city: string): WeatherData { + return { + city, + current: { + temp: 0, + feelsLike: 0, + humidity: 0, + pressure: 0, + windSpeed: 0, + conditions: 'Unknown', + description: '', + iconCode: '01d' + }, + forecast: [], + fetchedAt: 0 + }; + } +} diff --git a/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts new file mode 100644 index 000000000..78db73acb --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/BaseWeatherWidgetModel.ts @@ -0,0 +1,298 @@ +import {createElement, ReactNode} from 'react'; +import {HoistModel, lookup, managed} from '@xh/hoist/core'; +import {DashCanvasViewModel, DashViewModel} from '@xh/hoist/desktop/cmp/dash'; +import {PanelModel} from '@xh/hoist/desktop/cmp/panel'; +import {FormModel} from '@xh/hoist/cmp/form'; +import {required, numberIs} from '@xh/hoist/data'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {Icon} from '@xh/hoist/icon'; +import {isEqual} from 'lodash'; +import {WidgetMeta, BindingSpec, ConfigPropertyDef} from '../dash/types'; +import {WiringModel} from '../dash/WiringModel'; +import {getInputWidgetColor, getConsumerWidgetColors} from '../dash/colorCoding'; +import {AppModel} from '../AppModel'; + +/** + * Abstract base class for all V2 weather dashboard widget models. + * + * Provides: + * - Access to the parent DashViewModel (for persistence). + * - Input resolution via the WiringModel. + * - Output publication via the WiringModel. + * - Modal-based settings support for configurable widgets. + * - Color-coded header indicators for widget linkages. + * + * Subclasses must define a static `meta: WidgetMeta` property + * and register it with the WidgetRegistry. + */ +export abstract class BaseWeatherWidgetModel extends HoistModel { + /** Static metadata — override in every subclass. */ + static meta: WidgetMeta; + + @lookup(() => DashViewModel) + viewModel: DashViewModel; + + /** PanelModel with modal support for the settings dialog. */ + @managed panelModel: PanelModel; + + /** FormModel for config property editing in the settings dialog. */ + @managed configFormModel: FormModel; + + /** True if this widget type has user-configurable settings. */ + get hasSettings(): boolean { + const meta = (this.constructor as any).meta as WidgetMeta; + if (!meta) return false; + return meta.inputs.length > 0 || Object.keys(meta.config).length > 0; + } + + constructor() { + super(); + const meta = (this.constructor as any).meta as WidgetMeta; + + // Always create PanelModel — every widget has at least the hidePanelHeader config. + this.panelModel = new PanelModel({ + modalSupport: { + width: '80vw', + height: '70vh', + canOutsideClickClose: true + }, + collapsible: false, + resizable: false + }); + + // Always create configFormModel — every widget has at least hidePanelHeader. + const configEntries = meta ? Object.entries(meta.config) : []; + this.configFormModel = new FormModel({ + fields: configEntries.map(([key, def]) => configPropertyToField(key, def)) + }); + } + + override onLinked() { + super.onLinked(); + this.persistWith = {dashViewModel: this.viewModel}; + + // Init configFormModel from current viewState and set up bidirectional sync. + if (this.configFormModel) { + const meta = (this.constructor as any).meta as WidgetMeta, + configKeys = Object.keys(meta.config); + + // Init form values from persisted viewState. + const initialValues: Record = {}; + for (const key of configKeys) { + initialValues[key] = this.viewModel.viewState?.[key] ?? meta.config[key].default; + } + this.configFormModel.init(initialValues); + + // Sync form → viewState: push form changes to persisted state. + this.addReaction({ + track: () => { + const vals: Record = {}; + for (const key of configKeys) vals[key] = this.configFormModel.values[key]; + return vals; + }, + run: vals => { + for (const key of configKeys) { + this.viewModel.setViewStateKey(key, vals[key]); + } + } + }); + + // Sync viewState → form: reflect external changes (e.g. LLM updates). + this.addReaction({ + track: () => { + const vs = this.viewModel.viewState ?? {}, + vals: Record = {}; + for (const key of configKeys) vals[key] = vs[key]; + return vals; + }, + run: vals => { + const formVals = this.configFormModel.values, + updates: Record = {}; + let hasUpdates = false; + for (const key of configKeys) { + if (!isEqual(vals[key], formVals[key])) { + updates[key] = vals[key]; + hasUpdates = true; + } + } + if (hasUpdates) this.configFormModel.setValues(updates); + } + }); + } + + // Reactively build header items: color indicators + gear button. + // Tracks canvasModel.viewModels so colors update when widgets are added/removed. + // Also tracks manualEditingEnabled so gear button visibility updates. + this.addReaction({ + track: () => { + const meta = (this.constructor as any).meta as WidgetMeta; + if (!meta) return null; + const vmId = this.viewModel.id; + // Track editing toggle to rebuild header items (gear visibility). + void AppModel.instance.manualEditingEnabled; + if (meta.category === 'input') return getInputWidgetColor(vmId); + if (meta.inputs.length > 0) return getConsumerWidgetColors(vmId); + return null; + }, + run: () => { + const canvasVM = this.viewModel as DashCanvasViewModel; + canvasVM.headerItems = this.buildHeaderItems(); + }, + fireImmediately: true, + delay: 1 + }); + + // Apply colored border to the outer DashCanvas card for input widgets. + // Tracked separately so it fires when the DOM ref becomes available + // (after mount) and when the color changes (widgets added/removed). + this.addReaction({ + track: () => { + const meta = (this.constructor as any).meta as WidgetMeta; + if (meta?.category !== 'input') return null; + const canvasVM = this.viewModel as DashCanvasViewModel; + const el = canvasVM.ref?.current; + if (!el) return null; + return getInputWidgetColor(this.viewModel.id) ?? null; + }, + run: color => { + const canvasVM = this.viewModel as DashCanvasViewModel, + el = canvasVM.ref?.current as HTMLElement | null; + if (!el) return; + if (color) { + el.style.borderLeft = `8px solid ${color}`; + } else { + el.style.borderLeft = ''; + } + }, + fireImmediately: true + }); + + // Sync hidePanelHeader based on viewState config + editing toggle. + // delay: 1 avoids modifying canvasVM observable state during render. + this.addReaction({ + track: () => ({ + editing: AppModel.instance.manualEditingEnabled, + hide: this.viewModel.viewState?.hidePanelHeader ?? false + }), + run: ({editing, hide}) => { + const canvasVM = this.viewModel as DashCanvasViewModel; + // While editing, always show header so gear is accessible. + // When locked, respect the widget's hidePanelHeader setting. + canvasVM.hidePanelHeader = editing ? false : hide; + }, + fireImmediately: true, + delay: 1 + }); + } + + //-------------------------------------------------- + // Header Items + //-------------------------------------------------- + + /** Build the header items array with color indicators and settings gear button. */ + private buildHeaderItems(): ReactNode[] { + const meta = (this.constructor as any).meta as WidgetMeta, + items: ReactNode[] = []; + + if (!meta) return items; + + const vmId = this.viewModel.id; + + // Color dot for input widgets + if (meta.category === 'input') { + const color = getInputWidgetColor(vmId); + if (color) items.push(colorDot(color)); + } + + // Color swatches for consumer widgets showing linked providers + if (meta.inputs.length > 0) { + const colors = getConsumerWidgetColors(vmId); + for (const color of colors) { + items.push(colorDot(color)); + } + } + + // Gear button — only visible when manual editing is enabled + if (this.panelModel && AppModel.instance.manualEditingEnabled) { + items.push( + button({ + icon: Icon.gear(), + minimal: true, + onClick: () => this.panelModel.toggleIsModal() + }) + ); + } + + return items; + } + + //-------------------------------------------------- + // Input Resolution + //-------------------------------------------------- + + /** + * Resolve a named input to its current value. + * + * Fallback chain: + * 1. Binding (fromWidget or const) → resolved value + * 2. Direct viewState value (manual / LLM-set) + * 3. undefined (unbound — widget needs reconfiguration) + * + * Input defaults are seeded into viewState at widget-addition time by + * WeatherV2DashModel, so there is no meta-default fallback here. + */ + protected resolveInput(inputName: string): T | undefined { + const viewState = this.viewModel.viewState; + + // 1. Resolve from binding (fromWidget or const) + const binding: BindingSpec | undefined = viewState?.bindings?.[inputName]; + if (binding) { + const resolved = this.wiringModel.resolveBinding(binding); + if (resolved !== undefined) return resolved as T; + } + + // 2. Fall back to direct state value (manual or seeded default) + const directValue = viewState?.[inputName]; + if (directValue !== undefined) return directValue as T; + + // 3. Unbound — no binding and no manual value + return undefined; + } + + //-------------------------------------------------- + // Output Publication + //-------------------------------------------------- + + /** Publish a named output value to the wiring model. */ + protected publishOutput(name: string, value: any) { + this.wiringModel.publishOutput(this.viewModel.id, name, value); + } + + //-------------------------------------------------- + // Internal + //-------------------------------------------------- + + /** Access to the shared WiringModel via AppModel singleton. */ + protected get wiringModel(): WiringModel { + return AppModel.instance.weatherV2DashModel.wiringModel; + } +} + +/** Create a small colored dot element for widget header color coding. */ +function colorDot(color: string): ReactNode { + return createElement('span', { + className: 'weather-v2-color-dot', + style: {backgroundColor: color}, + key: `color-${color}` + }); +} + +/** Map a ConfigPropertyDef to a FormModel field spec. */ +function configPropertyToField(key: string, def: ConfigPropertyDef) { + const rules = []; + if (def.required) rules.push(required); + if (def.type === 'number' && (def.min != null || def.max != null)) { + rules.push(numberIs({min: def.min, max: def.max})); + } + return {name: key, initialValue: def.default ?? null, rules}; +} diff --git a/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts new file mode 100644 index 000000000..a7237e682 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/CityChooserWidget.ts @@ -0,0 +1,149 @@ +import {hoistCmp, creates} from '@xh/hoist/core'; +import {select} from '@xh/hoist/desktop/cmp/input'; +import {box} from '@xh/hoist/cmp/layout'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {WidgetMeta} from '../dash/types'; + +/** + * Curated list of major cities known to work well with the OpenWeatherMap API. + * Shown as structured suggestions in the dropdown, but users and the LLM can + * also enter any city name — the weather API accepts any valid city worldwide. + */ +export const CITIES = [ + 'Atlanta', + 'Austin', + 'Bangkok', + 'Berlin', + 'Boston', + 'Buenos Aires', + 'Cairo', + 'Chicago', + 'Dallas', + 'Denver', + 'Dubai', + 'Houston', + 'Hong Kong', + 'Istanbul', + 'Las Vegas', + 'London', + 'Los Angeles', + 'Madrid', + 'Mexico City', + 'Miami', + 'Minneapolis', + 'Mumbai', + 'Nashville', + 'New York', + 'Paris', + 'Philadelphia', + 'Phoenix', + 'Portland', + 'Rome', + 'San Antonio', + 'San Diego', + 'San Francisco', + 'Seattle', + 'Seoul', + 'Shanghai', + 'Singapore', + 'Sydney', + 'Tokyo', + 'Toronto', + 'Vancouver' +]; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class CityChooserModel extends BaseWeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'cityChooser', + title: 'City Chooser', + description: + 'Dropdown selector that emits the selected city name. Includes a curated list of major cities but also accepts any city name supported by the weather API.', + category: 'input', + inputs: [], + outputs: [ + {name: 'selectedCity', type: 'city', description: 'The currently selected city name.'} + ], + config: { + enableSearch: { + type: 'boolean', + description: 'Enable type-ahead filtering in the dropdown.', + default: true + }, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } + }, + defaultSize: {w: 3, h: 3}, + idealSize: {h: 3}, + minSize: {w: 2, h: 3} + }; + + @bindable selectedCity: string = 'New York'; + + constructor() { + super(); + makeObservable(this); + } + + override onLinked() { + super.onLinked(); + this.markPersist('selectedCity'); + + // Publish output whenever city changes. + // delay: 1 avoids modifying observable state synchronously during the + // React render cycle that triggers onLinked (fireImmediately + @action + // on publishOutput would otherwise cause "can't update state while + // rendering" warnings from React). + this.addReaction({ + track: () => this.selectedCity, + run: city => this.publishOutput('selectedCity', city), + fireImmediately: true, + delay: 1 + }); + } + + get cities(): string[] { + return this.viewModel.viewState?.cities ?? CITIES; + } + + get enableSearch(): boolean { + return this.viewModel.viewState?.enableSearch ?? true; + } +} + +widgetRegistry.register(CityChooserModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const cityChooserWidget = hoistCmp.factory({ + displayName: 'CityChooserWidget', + model: creates(CityChooserModel), + + render({model}) { + const content = box({ + testId: 'city-chooser', + padding: 8, + flex: 1, + alignItems: 'center', + item: select({ + testId: 'city-select', + bind: 'selectedCity', + options: model.cities, + enableFilter: model.enableSearch, + enableCreate: true, + createMessageFn: q => `Use "${q}"`, + width: '100%' + }) + }); + return settingsAwarePanel(model, content); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts new file mode 100644 index 000000000..66783ae48 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/CurrentConditionsWidget.ts @@ -0,0 +1,258 @@ +import {chart, ChartModel} from '@xh/hoist/cmp/chart'; +import {div, hbox, img, vbox} from '@xh/hoist/cmp/layout'; +import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {computed, makeObservable} from '@xh/hoist/mobx'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {fmtTemp, fmtWind} from '../dash/unitUtils'; +import {WidgetMeta} from '../dash/types'; +import {WeatherData} from '../Types'; +import '../WeatherV2.scss'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class CurrentConditionsModel extends BaseWeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'currentConditions', + title: 'Current Conditions', + description: + 'Current weather snapshot — temperature gauge, conditions icon, and key details.', + category: 'display', + inputs: [ + { + name: 'city', + type: 'city', + required: true, + default: 'New York', + description: 'City to display.' + }, + { + name: 'units', + type: 'units', + required: false, + default: 'imperial', + enum: ['imperial', 'metric'], + description: 'Unit system: "imperial" or "metric".' + } + ], + outputs: [], + config: { + showFeelsLike: {type: 'boolean', default: true}, + showHumidity: {type: 'boolean', default: true}, + showWind: {type: 'boolean', default: true}, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } + }, + defaultSize: {w: 4, h: 8}, + minSize: {w: 3, h: 5} + }; + + @managed chartModel: ChartModel; + + constructor() { + super(); + makeObservable(this); + } + + @computed + get city(): string { + return this.resolveInput('city') ?? 'New York'; + } + + @computed + get units(): string { + return this.resolveInput('units') ?? 'imperial'; + } + + get showFeelsLike(): boolean { + return this.viewModel.viewState?.showFeelsLike ?? true; + } + + get showHumidity(): boolean { + return this.viewModel.viewState?.showHumidity ?? true; + } + + get showWind(): boolean { + return this.viewModel.viewState?.showWind ?? true; + } + + override onLinked() { + super.onLinked(); + + this.chartModel = this.createChartModel(); + + this.addReaction({ + track: () => this.city, + run: () => this.loadAsync(), + fireImmediately: true + }); + + // Update chart when data or units change + this.addReaction({ + track: () => [this.weatherData, this.units], + run: () => this.updateChart(), + fireImmediately: true + }); + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {city} = this; + if (!city) return; + try { + await XH.weatherDataService.ensureDataAsync(city, loadSpec); + } catch (e) { + // Data model handles caching — errors will show via lastLoadException + } + } + + @computed + get weatherData(): WeatherData | null { + return XH.weatherDataService.getData(this.city); + } + + private createChartModel(): ChartModel { + return new ChartModel({ + highchartsConfig: { + chart: {type: 'solidgauge'}, + title: {text: null}, + pane: { + center: ['50%', '70%'], + size: '130%', + startAngle: -90, + endAngle: 90, + background: { + innerRadius: '60%', + outerRadius: '100%', + shape: 'arc', + borderWidth: 0, + backgroundColor: '#eeeeee' + } + }, + yAxis: { + min: this.units === 'metric' ? -30 : -20, + max: this.units === 'metric' ? 50 : 120, + lineWidth: 0, + tickWidth: 0, + minorTickInterval: null, + tickAmount: 2, + labels: {y: 16, style: {fontSize: '12px'}}, + stops: [ + [0.15, '#2196F3'], + [0.4, '#f7931c'], + [0.6, '#FF9800'], + [0.85, '#F44336'] + ] + }, + tooltip: {enabled: false}, + credits: {enabled: false}, + plotOptions: { + solidgauge: { + dataLabels: { + y: -25, + borderWidth: 0, + useHTML: true + } + } + }, + series: [{name: 'Temperature', data: [0], innerRadius: '60%'}] + } + }); + } + + private updateChart() { + const data = this.weatherData; + if (!data?.current) return; + + const {units} = this, + temp = data.current.temp, + displayTemp = fmtTemp(temp, units); + + const chartTemp = units === 'metric' ? Math.round(((temp - 32) * 5) / 9) : Math.round(temp); + + this.chartModel.setSeries([ + { + name: 'Temperature', + data: [chartTemp], + innerRadius: '60%', + dataLabels: { + format: `
${displayTemp}
` + } + } + ]); + + // Update gauge range for units + const yAxis = units === 'metric' ? {min: -30, max: 50} : {min: -20, max: 120}; + this.chartModel.updateHighchartsConfig({yAxis}); + } +} + +widgetRegistry.register(CurrentConditionsModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const currentConditionsWidget = hoistCmp.factory({ + displayName: 'CurrentConditionsWidget', + model: creates(CurrentConditionsModel), + + render({model}) { + const data = model.weatherData; + if (!data?.current) return null; + + const {current} = data, + {units} = model; + + const details = []; + if (model.showFeelsLike && current.feelsLike != null) { + details.push(`Feels like ${fmtTemp(current.feelsLike, units)}`); + } + if (model.showHumidity && current.humidity != null) { + details.push(`Humidity: ${current.humidity}%`); + } + if (model.showWind && current.windSpeed != null) { + details.push(`Wind: ${fmtWind(current.windSpeed, units)}`); + } + + const description = + current.description.charAt(0).toUpperCase() + current.description.slice(1); + + const content = vbox({ + testId: 'current-conditions', + className: 'weather-v2-current-conditions', + alignItems: 'center', + flex: 1, + items: [ + chart({className: 'weather-v2-current-conditions__gauge'}), + hbox({ + className: 'weather-v2-current-conditions__details', + items: [ + current.iconCode + ? img({ + className: 'weather-v2-current-conditions__icon', + src: `https://openweathermap.org/img/wn/${current.iconCode}@2x.png`, + alt: description + }) + : null, + vbox({ + className: 'weather-v2-current-conditions__text', + items: [ + div({ + className: 'weather-v2-current-conditions__description', + item: description + }), + div({item: details.join(' · ')}) + ] + }) + ] + }) + ] + }); + + return settingsAwarePanel(model, content); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts new file mode 100644 index 000000000..8ed80eb93 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/DashInspectorWidget.ts @@ -0,0 +1,125 @@ +import {grid, GridModel} from '@xh/hoist/cmp/grid'; +import {creates, hoistCmp, managed} from '@xh/hoist/core'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {computed, makeObservable} from '@xh/hoist/mobx'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {WidgetMeta} from '../dash/types'; +import {AppModel} from '../AppModel'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class DashInspectorModel extends BaseWeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'dashInspector', + title: 'Dash Inspector', + description: + 'Debug utility showing all active widgets, their bindings, and live output values.', + category: 'utility', + inputs: [], + outputs: [], + config: { + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } + }, + defaultSize: {w: 6, h: 8}, + minSize: {w: 4, h: 5} + }; + + @managed gridModel: GridModel; + + constructor() { + super(); + makeObservable(this); + } + + override onLinked() { + super.onLinked(); + this.gridModel = this.createGridModel(); + + this.addReaction({ + track: () => this.inspectorData, + run: data => this.gridModel.loadData(data) + }); + } + + @computed + get inspectorData(): Record[] { + const dashModel = AppModel.instance.weatherV2DashModel, + wiringModel = dashModel.wiringModel, + canvasModel = dashModel.dashCanvasModel, + allOutputs = wiringModel.allOutputs; + + const state = canvasModel.getPersistableState()?.value?.state ?? []; + return state.map((item: any, idx: number) => { + const specId = item.viewSpecId, + meta = widgetRegistry.get(specId), + bindings = item.state?.bindings, + widgetOutputs = allOutputs.get(item.id); + + return { + id: idx, + instanceId: item.id ?? specId, + widgetType: meta?.title ?? specId, + category: meta?.category ?? '—', + bindings: bindings ? formatBindings(bindings) : '—', + outputs: widgetOutputs ? formatOutputs(widgetOutputs) : '—' + }; + }); + } + + private createGridModel(): GridModel { + return new GridModel({ + sortBy: 'instanceId', + emptyText: 'No widgets in dashboard.', + columns: [ + {field: 'instanceId', headerName: 'Instance', width: 140}, + {field: 'widgetType', headerName: 'Type', width: 120}, + {field: 'category', headerName: 'Category', width: 80}, + {field: 'bindings', headerName: 'Bindings', flex: 1}, + {field: 'outputs', headerName: 'Outputs', flex: 1} + ] + }); + } +} + +widgetRegistry.register(DashInspectorModel.meta); + +//-------------------------------------------------- +// Helpers +//-------------------------------------------------- +function formatBindings(bindings: Record): string { + return Object.entries(bindings) + .map(([input, spec]) => { + if ('const' in spec) return `${input}=${JSON.stringify(spec.const)}`; + if ('fromWidget' in spec) return `${input}←${spec.fromWidget}.${spec.output}`; + return `${input}=?`; + }) + .join(', '); +} + +function formatOutputs(outputs: Map): string { + return Array.from(outputs.entries()) + .map(([name, value]) => { + const display = + typeof value === 'string' ? value : value != null ? JSON.stringify(value) : 'null'; + return `${name}=${display}`; + }) + .join(', '); +} + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const dashInspectorWidget = hoistCmp.factory({ + displayName: 'DashInspectorWidget', + model: creates(DashInspectorModel), + + render() { + return panel({testId: 'dash-inspector', item: grid()}); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts new file mode 100644 index 000000000..bc9f94360 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/ForecastChartWidget.ts @@ -0,0 +1,223 @@ +import {chart, ChartModel} from '@xh/hoist/cmp/chart'; +import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {fmtDate} from '@xh/hoist/format'; +import {computed, makeObservable} from '@xh/hoist/mobx'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {convertTemp, tempUnit} from '../dash/unitUtils'; +import {WidgetMeta} from '../dash/types'; +import {WeatherData} from '../Types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class ForecastChartModel extends BaseWeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'forecastChart', + title: 'Forecast Chart', + description: + 'Multi-series line/area/column chart showing forecast data over time. Configurable series selection and chart type.', + category: 'display', + inputs: [ + { + name: 'city', + type: 'city', + required: true, + default: 'New York', + description: 'City to show forecast for.' + }, + { + name: 'units', + type: 'units', + required: false, + default: 'imperial', + enum: ['imperial', 'metric'], + description: 'Unit system: "imperial" or "metric".' + } + ], + outputs: [], + config: { + series: { + type: 'string[]', + description: + 'Data series to display. Options: "temp", "feelsLike", "humidity", "pressure".', + default: ['temp', 'feelsLike'] + }, + chartType: { + type: 'enum', + enum: ['line', 'area', 'column'], + default: 'line' + }, + showLegend: {type: 'boolean', default: true}, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } + }, + defaultSize: {w: 8, h: 8}, + minSize: {w: 4, h: 5} + }; + + @managed chartModel: ChartModel; + + constructor() { + super(); + makeObservable(this); + } + + @computed get city(): string { + return this.resolveInput('city') ?? 'New York'; + } + + @computed get units(): string { + return this.resolveInput('units') ?? 'imperial'; + } + + get activeSeries(): string[] { + return this.viewModel.viewState?.series ?? ['temp', 'feelsLike']; + } + + get chartType(): string { + return this.viewModel.viewState?.chartType ?? 'line'; + } + + get showLegend(): boolean { + return this.viewModel.viewState?.showLegend ?? true; + } + + override onLinked() { + super.onLinked(); + this.chartModel = this.createChartModel(); + + this.addReaction({ + track: () => this.city, + run: () => this.loadAsync(), + fireImmediately: true + }); + + this.addReaction({ + track: () => [this.weatherData, this.units, this.activeSeries, this.chartType], + run: () => this.updateChart(), + fireImmediately: true + }); + + this.addReaction({ + track: () => this.showLegend, + run: showLegend => + this.chartModel.updateHighchartsConfig({legend: {enabled: showLegend}}) + }); + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {city} = this; + if (!city) return; + try { + await XH.weatherDataService.ensureDataAsync(city, loadSpec); + } catch (e) { + // Handled by WeatherDataService + } + } + + @computed get weatherData(): WeatherData | null { + return XH.weatherDataService.getData(this.city); + } + + private createChartModel(): ChartModel { + return new ChartModel({ + highchartsConfig: { + chart: {zoomType: 'x'}, + title: {text: null}, + xAxis: { + type: 'datetime', + labels: { + formatter: function () { + return fmtDate(this.value, {fmt: 'ddd ha'}); + } + } + }, + yAxis: { + title: {text: null}, + labels: {format: '{value}'} + }, + tooltip: { + shared: true, + xDateFormat: '%A %b %e, %l:%M %p' + }, + legend: {enabled: this.showLegend}, + credits: {enabled: false} + } + }); + } + + private updateChart() { + const data = this.weatherData; + if (!data?.forecast?.length) return; + + const {forecast} = data, + {units, activeSeries, chartType} = this, + tUnit = tempUnit(units), + seriesData = []; + + const seriesConfig: Record = { + temp: { + name: 'Temperature', + color: '#7cb5ec', + suffix: tUnit, + convert: (v: number) => convertTemp(v, units) + }, + feelsLike: { + name: 'Feels Like', + color: '#f45b5b', + suffix: tUnit, + convert: (v: number) => convertTemp(v, units), + dashStyle: 'ShortDash' + }, + humidity: {name: 'Humidity', color: '#90ed7d', suffix: '%', convert: (v: number) => v}, + pressure: { + name: 'Pressure', + color: '#8085e9', + suffix: ' hPa', + convert: (v: number) => v + } + }; + + for (const key of activeSeries) { + const cfg = seriesConfig[key]; + if (!cfg) continue; + + const points = forecast.map(entry => [ + entry.dt, + Math.round(cfg.convert(entry[key] ?? entry.temp) * 10) / 10 + ]); + + seriesData.push({ + name: cfg.name, + type: chartType === 'line' ? 'spline' : chartType, + data: points, + color: cfg.color, + lineWidth: 2, + marker: {enabled: false}, + dashStyle: cfg.dashStyle, + tooltip: {valueSuffix: cfg.suffix} + }); + } + + this.chartModel.setSeries(seriesData); + } +} + +widgetRegistry.register(ForecastChartModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const forecastChartWidget = hoistCmp.factory({ + displayName: 'ForecastChartWidget', + model: creates(ForecastChartModel), + + render({model}) { + return settingsAwarePanel(model, chart()); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts new file mode 100644 index 000000000..4a1cee69b --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/MarkdownContentWidget.ts @@ -0,0 +1,74 @@ +import {hoistCmp, creates} from '@xh/hoist/core'; +import {div} from '@xh/hoist/cmp/layout'; +import {markdown} from '@xh/hoist/cmp/markdown'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {WidgetMeta} from '../dash/types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class MarkdownContentModel extends BaseWeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'markdownContent', + title: 'Markdown Content', + description: + 'Static rich-text display. Useful for dashboard titles, instructions, or annotations.', + category: 'utility', + inputs: [], + outputs: [], + config: { + title: { + type: 'string', + description: + 'Display title for the widget header. Set this to label the widget since its title cannot be auto-generated.', + default: 'Markdown Content' + }, + content: { + type: 'markdown', + default: "# Welcome\n\nEdit this widget's content in the dashboard spec." + }, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } + }, + defaultSize: {w: 4, h: 5}, + minSize: {w: 2, h: 2} + }; + + @bindable content: string = "# Welcome\n\nEdit this widget's content in the dashboard spec."; + @bindable widgetTitle: string = 'Markdown Content'; + + constructor() { + super(); + makeObservable(this); + } + + override onLinked() { + super.onLinked(); + this.markPersist('content'); + this.markPersist('widgetTitle', {path: 'title'}); + } +} + +widgetRegistry.register(MarkdownContentModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const markdownContentWidget = hoistCmp.factory({ + displayName: 'MarkdownContentWidget', + model: creates(MarkdownContentModel), + + render({model}) { + const content = div({ + className: 'weather-v2-markdown', + item: markdown({content: model.content, lineBreaks: false}) + }); + return settingsAwarePanel(model, content); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts new file mode 100644 index 000000000..e57a57d18 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/PrecipChartWidget.ts @@ -0,0 +1,216 @@ +import {chart, ChartModel} from '@xh/hoist/cmp/chart'; +import {placeholder} from '@xh/hoist/cmp/layout'; +import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {fmtDate} from '@xh/hoist/format'; +import {Icon} from '@xh/hoist/icon'; +import {bindable, computed, makeObservable} from '@xh/hoist/mobx'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {WidgetMeta} from '../dash/types'; +import {WeatherData} from '../Types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class PrecipChartModel extends BaseWeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'precipChart', + title: 'Precipitation', + description: + 'Precipitation probability and volume over the forecast period. Dual-axis column chart.', + category: 'display', + inputs: [ + { + name: 'city', + type: 'city', + required: true, + default: 'New York', + description: 'City to show precipitation for.' + } + ], + outputs: [], + config: { + metric: { + type: 'enum', + enum: ['probability', 'volume', 'both'], + default: 'both' + }, + showThresholds: { + type: 'boolean', + description: 'Highlight high-probability periods.', + default: false + }, + showLegend: {type: 'boolean', default: true}, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } + }, + defaultSize: {w: 6, h: 8}, + minSize: {w: 4, h: 5} + }; + + @managed chartModel: ChartModel; + @bindable hasData: boolean = false; + + constructor() { + super(); + makeObservable(this); + } + + @computed get city(): string { + return this.resolveInput('city') ?? 'New York'; + } + + get displayMetric(): string { + return this.viewModel.viewState?.metric ?? 'both'; + } + + get showLegend(): boolean { + return this.viewModel.viewState?.showLegend ?? true; + } + + override onLinked() { + super.onLinked(); + this.chartModel = this.createChartModel(); + + this.addReaction({ + track: () => this.city, + run: () => this.loadAsync(), + fireImmediately: true + }); + + this.addReaction({ + track: () => [this.weatherData, this.displayMetric], + run: () => this.updateChart(), + fireImmediately: true + }); + + this.addReaction({ + track: () => this.showLegend, + run: showLegend => + this.chartModel.updateHighchartsConfig({legend: {enabled: showLegend}}) + }); + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {city} = this; + if (!city) return; + try { + await XH.weatherDataService.ensureDataAsync(city, loadSpec); + } catch (e) { + // Handled by WeatherDataService + } + } + + @computed get weatherData(): WeatherData | null { + return XH.weatherDataService.getData(this.city); + } + + private createChartModel(): ChartModel { + return new ChartModel({ + highchartsConfig: { + chart: {zoomType: 'x'}, + title: {text: null}, + xAxis: { + type: 'datetime', + labels: { + formatter: function () { + return fmtDate(this.value, {fmt: 'ddd ha'}); + } + } + }, + yAxis: [ + { + title: {text: 'Probability (%)'}, + max: 100, + min: 0, + labels: {format: '{value}%'} + }, + { + title: {text: 'Volume (mm)'}, + min: 0, + opposite: true, + labels: {format: '{value} mm'} + } + ], + tooltip: { + shared: true, + xDateFormat: '%A %b %e, %l:%M %p' + }, + legend: {enabled: true}, + credits: {enabled: false} + } + }); + } + + private updateChart() { + const data = this.weatherData; + if (!data?.forecast?.length) return; + + const {forecast} = data, + {displayMetric} = this, + probData = [], + volumeData = []; + + let anyPrecip = false; + + for (const entry of forecast) { + const time = entry.dt; + if (entry.precipProbability > 0 || entry.precipVolume > 0) anyPrecip = true; + probData.push([time, entry.precipProbability]); + volumeData.push([time, Math.round(entry.precipVolume * 10) / 10]); + } + + this.hasData = anyPrecip; + + if (!anyPrecip) return; + + const series = []; + if (displayMetric === 'probability' || displayMetric === 'both') { + series.push({ + name: 'Probability', + type: 'column', + data: probData, + yAxis: 0, + color: 'rgba(135, 175, 220, 0.6)', + borderWidth: 0, + tooltip: {valueSuffix: '%'} + }); + } + if (displayMetric === 'volume' || displayMetric === 'both') { + series.push({ + name: 'Rain Volume', + type: 'column', + data: volumeData, + yAxis: 1, + color: '#2171b5', + borderWidth: 0, + tooltip: {valueSuffix: ' mm'} + }); + } + + this.chartModel.setSeries(series); + } +} + +widgetRegistry.register(PrecipChartModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const precipChartWidget = hoistCmp.factory({ + displayName: 'PrecipChartWidget', + model: creates(PrecipChartModel), + + render({model}) { + const content = model.hasData + ? chart() + : placeholder({ + items: [Icon.sun(), 'No precipitation expected in the forecast period'] + }); + return settingsAwarePanel(model, content); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts new file mode 100644 index 000000000..ace4da501 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/SummaryGridWidget.ts @@ -0,0 +1,216 @@ +import {grid, GridAutosizeMode, GridModel} from '@xh/hoist/cmp/grid'; +import {img} from '@xh/hoist/cmp/layout'; +import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {ColChooserModel} from '@xh/hoist/desktop/cmp/grid/impl/colchooser/ColChooserModel'; +import {computed, makeObservable} from '@xh/hoist/mobx'; +import {groupBy} from 'lodash'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {fmtTemp, fmtWind} from '../dash/unitUtils'; +import {WidgetMeta} from '../dash/types'; +import {WeatherData} from '../Types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class SummaryGridModel extends BaseWeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'summaryGrid', + title: '5-Day Summary', + description: + 'Tabular daily overview — one row per day with high/low, conditions, humidity, wind.', + category: 'display', + inputs: [ + { + name: 'city', + type: 'city', + required: true, + default: 'New York', + description: 'City to summarize.' + }, + { + name: 'units', + type: 'units', + required: false, + default: 'imperial', + enum: ['imperial', 'metric'], + description: 'Unit system.' + } + ], + outputs: [], + config: { + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } + }, + defaultSize: {w: 6, h: 8}, + minSize: {w: 4, h: 5} + }; + + @managed gridModel: GridModel; + + constructor() { + super(); + makeObservable(this); + } + + @computed get city(): string { + return this.resolveInput('city') ?? 'New York'; + } + + @computed get units(): string { + return this.resolveInput('units') ?? 'imperial'; + } + + override onLinked() { + super.onLinked(); + this.gridModel = this.createGridModel(); + + this.addReaction({ + track: () => this.city, + run: () => this.loadAsync(), + fireImmediately: true + }); + + this.addReaction({ + track: () => [this.weatherData, this.units], + run: () => this.updateGrid(), + fireImmediately: true + }); + + // Sync chooser data when settings modal opens so the LeftRightChooser + // reflects the current column visibility state. + this.addReaction({ + track: () => this.panelModel.isModal, + run: isModal => { + if (isModal) { + (this.gridModel.colChooserModel as ColChooserModel).syncChooserData(); + } + } + }); + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {city} = this; + if (!city) return; + try { + await XH.weatherDataService.ensureDataAsync(city, loadSpec); + } catch (e) { + // Handled by WeatherDataService + } + } + + @computed get weatherData(): WeatherData | null { + return XH.weatherDataService.getData(this.city); + } + + private createGridModel(): GridModel { + return new GridModel({ + sortBy: 'date', + emptyText: 'No forecast data available.', + autosizeOptions: {mode: GridAutosizeMode.MANAGED}, + colChooserModel: {commitOnChange: true}, + persistWith: { + dashViewModel: this.viewModel, + persistSort: false, + persistGrouping: false + }, + columns: [ + { + field: 'date', + headerName: 'Day', + width: 120, + renderer: v => { + const d = new Date(v); + return d.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + }); + } + }, + { + field: 'icon', + headerName: '', + chooserName: 'Icon', + width: 50, + rendererIsComplex: true, + renderer: (v, {record}) => { + return img({ + src: `https://openweathermap.org/img/wn/${v}.png`, + alt: record.data.conditions, + width: 32, + height: 32 + }); + }, + exportValue: (_v, {record}) => record.data.conditions + }, + {field: 'conditions', headerName: 'Conditions', width: 150}, + {field: 'high', headerName: 'High', width: 70, align: 'right'}, + {field: 'low', headerName: 'Low', width: 70, align: 'right'}, + { + field: 'humidity', + headerName: 'Humidity', + width: 80, + align: 'right', + renderer: v => `${v}%` + }, + {field: 'wind', headerName: 'Wind', width: 80, align: 'right'} + ] + }); + } + + /** Build grid rows with pre-formatted values so cell data changes when units change. */ + private updateGrid() { + const data = this.weatherData; + if (!data?.forecast?.length) { + this.gridModel.clear(); + return; + } + + const {units} = this; + + const byDay = groupBy(data.forecast, entry => { + const d = new Date(entry.dt); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + }); + + const rows = Object.entries(byDay).map(([_key, dayItems], idx) => { + const highs = dayItems.map(it => it.tempMax), + lows = dayItems.map(it => it.tempMin), + humidities = dayItems.map(it => it.humidity), + winds = dayItems.map(it => it.windSpeed), + midItem = dayItems[Math.floor(dayItems.length / 2)]; + + return { + id: idx, + date: dayItems[0].dt, + icon: midItem.iconCode, + conditions: midItem.conditions, + high: fmtTemp(Math.max(...highs), units), + low: fmtTemp(Math.min(...lows), units), + humidity: Math.round(humidities.reduce((a, b) => a + b, 0) / humidities.length), + wind: fmtWind(winds.reduce((a, b) => a + b, 0) / winds.length, units) + }; + }); + + this.gridModel.loadData(rows); + } +} + +widgetRegistry.register(SummaryGridModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const summaryGridWidget = hoistCmp.factory({ + displayName: 'SummaryGridWidget', + model: creates(SummaryGridModel), + + render({model}) { + return settingsAwarePanel(model, grid()); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts new file mode 100644 index 000000000..80a1fbe77 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/UnitsToggleWidget.ts @@ -0,0 +1,90 @@ +import {hoistCmp, creates} from '@xh/hoist/core'; +import {box} from '@xh/hoist/cmp/layout'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {buttonGroupInput} from '@xh/hoist/desktop/cmp/input'; +import {bindable, makeObservable} from '@xh/hoist/mobx'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {WidgetMeta} from '../dash/types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class UnitsToggleModel extends BaseWeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'unitsToggle', + title: 'Units Toggle', + description: + 'Toggle between imperial and metric units. Display widgets that accept a units input will adapt.', + category: 'input', + inputs: [], + outputs: [ + {name: 'units', type: 'units', description: 'Unit system: "imperial" or "metric".'} + ], + config: { + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } + }, + defaultSize: {w: 3, h: 3}, + idealSize: {h: 3}, + minSize: {w: 2, h: 3} + }; + + @bindable units: string = 'imperial'; + + constructor() { + super(); + makeObservable(this); + } + + override onLinked() { + super.onLinked(); + this.markPersist('units'); + + // delay: 1 avoids modifying observable state synchronously during the + // React render cycle that triggers onLinked. + this.addReaction({ + track: () => this.units, + run: units => this.publishOutput('units', units), + fireImmediately: true, + delay: 1 + }); + } +} + +widgetRegistry.register(UnitsToggleModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const unitsToggleWidget = hoistCmp.factory({ + displayName: 'UnitsToggleWidget', + model: creates(UnitsToggleModel), + + render({model}) { + const content = box({ + testId: 'units-toggle', + padding: 8, + alignItems: 'center', + justifyContent: 'center', + flex: 1, + item: buttonGroupInput({ + testId: 'units-input', + bind: 'units', + intent: 'primary', + outlined: true, + width: '100%', + maxWidth: 300, + items: [ + button({text: '°F / mph', value: 'imperial', flex: 1}), + button({text: '°C / m/s', value: 'metric', flex: 1}) + ] + }) + }); + return settingsAwarePanel(model, content); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts b/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts new file mode 100644 index 000000000..f1fb59274 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/WidgetSettingsForm.ts @@ -0,0 +1,385 @@ +import {hoistCmp, uses} from '@xh/hoist/core'; +import {GridModel} from '@xh/hoist/cmp/grid'; +import {div, filler, span, vbox} from '@xh/hoist/cmp/layout'; +import {form} from '@xh/hoist/cmp/form'; +import {genDisplayName} from '@xh/hoist/data'; +import {formField} from '@xh/hoist/desktop/cmp/form'; +import {colChooser} from '@xh/hoist/desktop/cmp/grid/impl/colchooser/ColChooser'; +import {ColChooserModel} from '@xh/hoist/desktop/cmp/grid/impl/colchooser/ColChooserModel'; +import { + codeInput, + select, + switchInput, + textInput, + numberInput, + buttonGroupInput +} from '@xh/hoist/desktop/cmp/input'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {Icon} from '@xh/hoist/icon'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {InputDef, ConfigPropertyDef, WidgetMeta} from '../dash/types'; +import {AppModel} from '../AppModel'; +import {getInputWidgetColor} from '../dash/colorCoding'; + +//-------------------------------------------------- +// Constants +//-------------------------------------------------- +const SOURCE_MANUAL = '__manual__'; +const SOURCE_UNBOUND = '__unbound__'; + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +/** + * Settings form for a configurable widget. + * Renders controls for the widget's declared inputs (with provider widget linkage) + * and config properties. Shown in the modal dialog alongside the widget preview. + */ +export const widgetSettingsForm = hoistCmp.factory({ + displayName: 'WidgetSettingsForm', + className: 'weather-v2-settings-form', + model: uses(BaseWeatherWidgetModel, {publishMode: 'none'}), + + render({model, className}) { + const meta = (model.constructor as any).meta as WidgetMeta; + if (!meta) return null; + + const {inputs, config} = meta, + hasInputs = inputs.length > 0, + hasConfig = Object.keys(config).length > 0; + + return panel({ + className, + scrollable: true, + tbar: toolbar({ + className: 'weather-v2-settings-form__header', + items: [ + Icon.gear(), + span({className: 'weather-v2-settings-form__title', item: 'Settings'}), + filler(), + button({ + icon: Icon.close(), + minimal: true, + onClick: () => model.panelModel.toggleIsModal() + }) + ] + }), + item: div({ + className: 'weather-v2-settings-form__body', + items: [ + hasInputs ? renderInputsSection(model, inputs) : null, + renderColumnChooserSection(model), + hasConfig ? renderConfigSection(model, config) : null + ] + }) + }); + } +}); + +//-------------------------------------------------- +// Inputs Section — configure where each input comes from +//-------------------------------------------------- +function renderInputsSection(widgetModel: BaseWeatherWidgetModel, inputs: InputDef[]) { + return vbox({ + className: 'weather-v2-settings-form__section', + items: [ + div({ + className: 'weather-v2-settings-form__section-title', + item: 'Inputs' + }), + ...inputs.map(inputDef => renderInputField(widgetModel, inputDef)) + ] + }); +} + +function renderInputField(widgetModel: BaseWeatherWidgetModel, inputDef: InputDef) { + const viewState = widgetModel.viewModel.viewState ?? {}, + bindings = viewState.bindings ?? {}, + binding = bindings[inputDef.name], + providers = findProviders(inputDef, widgetModel.viewModel.id), + currentSource = determineSource(binding, viewState[inputDef.name]), + isLinked = currentSource !== SOURCE_MANUAL && currentSource !== SOURCE_UNBOUND, + isUnbound = currentSource === SOURCE_UNBOUND, + manualValue = viewState[inputDef.name] ?? ''; + + const sourceOptions = [ + {label: 'Set manually...', value: SOURCE_MANUAL}, + ...providers.map(p => ({ + label: formatProviderLabel(p), + value: `${p.widgetId}:${p.outputName}` + })) + ]; + + // Show "Not configured" only when currently unbound — once the user picks + // a real source it disappears from the dropdown. + if (isUnbound) { + sourceOptions.unshift({label: 'Not configured', value: SOURCE_UNBOUND}); + } + + return vbox({ + className: 'weather-v2-settings-form__field', + items: [ + div({ + className: `weather-v2-settings-form__field-label${isUnbound && inputDef.required ? ' weather-v2-settings-form__field-label--warning' : ''}`, + items: [ + isUnbound && inputDef.required + ? Icon.warning({className: 'weather-v2-settings-form__warning-icon'}) + : null, + genDisplayName(inputDef.name) + ] + }), + select({ + value: currentSource, + options: sourceOptions, + width: '100%', + onChange: (val: string) => handleSourceChange(widgetModel, inputDef, val) + }), + currentSource === SOURCE_MANUAL + ? renderManualInput(widgetModel, inputDef, manualValue) + : null, + isLinked + ? div({ + className: 'weather-v2-settings-form__linked-note', + items: [Icon.link(), span(' Linked to provider widget')] + }) + : null, + isUnbound + ? div({ + className: 'weather-v2-settings-form__unbound-note', + items: [ + Icon.warning(), + span( + inputDef.required + ? ' Required input — select a source' + : ' No source configured' + ) + ] + }) + : null + ] + }); +} + +//-------------------------------------------------- +// Column Chooser Section — native LeftRightChooser for grid widgets +//-------------------------------------------------- +function renderColumnChooserSection(widgetModel: BaseWeatherWidgetModel) { + const gridModel = (widgetModel as any).gridModel as GridModel | undefined; + if (!gridModel) return null; + + const colChooserModel = gridModel.colChooserModel as ColChooserModel; + if (!colChooserModel) return null; + + return vbox({ + className: 'weather-v2-settings-form__section', + items: [ + div({ + className: 'weather-v2-settings-form__section-title', + item: 'Columns' + }), + colChooser({model: colChooserModel}) + ] + }); +} + +//-------------------------------------------------- +// Config Section — widget-specific configuration via FormModel +//-------------------------------------------------- +function renderConfigSection( + widgetModel: BaseWeatherWidgetModel, + config: Record +) { + const {configFormModel} = widgetModel; + if (!configFormModel) return null; + + return vbox({ + className: 'weather-v2-settings-form__section', + items: [ + div({ + className: 'weather-v2-settings-form__section-title', + item: 'Configuration' + }), + form({ + model: configFormModel, + fieldDefaults: {commitOnChange: true}, + items: Object.entries(config).map(([key, def]) => renderConfigField(key, def)) + }) + ] + }); +} + +function renderConfigField(configKey: string, configDef: ConfigPropertyDef) { + const info = configDef.description || undefined; + + switch (configDef.type) { + case 'boolean': + return formField({field: configKey, info, item: switchInput({label: null})}); + + case 'enum': + if (configDef.enum && configDef.enum.length <= 3) { + return formField({ + field: configKey, + info, + item: buttonGroupInput({ + outlined: true, + items: configDef.enum.map(opt => button({text: opt, value: opt, flex: 1})) + }) + }); + } + return formField({ + field: configKey, + info, + item: select({options: configDef.enum ?? []}) + }); + + case 'number': + return formField({ + field: configKey, + info, + item: numberInput({min: configDef.min, max: configDef.max}) + }); + + case 'string[]': { + const knownOptions = extractOptionsFromDesc(configDef.description); + if (knownOptions.length > 0) { + return formField({ + field: configKey, + info, + item: select({enableMulti: true, enableFilter: false, options: knownOptions}) + }); + } + return formField({ + field: configKey, + info, + item: textInput({placeholder: 'Comma-separated values...'}) + }); + } + + case 'markdown': + return formField({ + field: configKey, + info, + item: codeInput({mode: 'markdown', showFullscreenButton: true}) + }); + + case 'string': + default: + return formField({field: configKey, info, item: textInput()}); + } +} + +//-------------------------------------------------- +// Helpers +//-------------------------------------------------- + +interface ProviderInfo { + widgetId: string; + outputName: string; + widgetTitle: string; + specId: string; +} + +/** Find all widget instances that produce outputs compatible with the given input. */ +function findProviders(inputDef: InputDef, currentWidgetId: string): ProviderInfo[] { + const dashModel = AppModel.instance.weatherV2DashModel, + canvasModel = dashModel.dashCanvasModel, + providers: ProviderInfo[] = []; + + for (const vm of canvasModel.viewModels) { + if (vm.id === currentWidgetId) continue; + const meta = widgetRegistry.get(vm.viewSpec.id); + if (!meta) continue; + for (const output of meta.outputs) { + if (output.type === inputDef.type) { + providers.push({ + widgetId: vm.id, + outputName: output.name, + widgetTitle: vm.title ?? meta.title, + specId: vm.viewSpec.id + }); + } + } + } + return providers; +} + +/** Determine the current source setting for an input from its binding/value. */ +function determineSource(binding: any, directValue: any): string { + if (binding) { + if ('fromWidget' in binding) return `${binding.fromWidget}:${binding.output}`; + if ('const' in binding) return SOURCE_MANUAL; + } + if (directValue !== undefined) return SOURCE_MANUAL; + return SOURCE_UNBOUND; +} + +/** Handle changes to the source dropdown for an input. */ +function handleSourceChange( + widgetModel: BaseWeatherWidgetModel, + inputDef: InputDef, + newSource: string +) { + // SOURCE_UNBOUND is a read-only display state — no-op + if (newSource === SOURCE_UNBOUND) return; + + const viewState = {...(widgetModel.viewModel.viewState ?? {})}, + bindings = {...(viewState.bindings ?? {})}; + + if (newSource === SOURCE_MANUAL) { + delete bindings[inputDef.name]; + viewState[inputDef.name] = inputDef.default ?? ''; + } else { + // Provider: "widgetId:outputName" + const [fromWidget, output] = newSource.split(':'); + bindings[inputDef.name] = {fromWidget, output}; + delete viewState[inputDef.name]; + } + + viewState.bindings = Object.keys(bindings).length > 0 ? bindings : undefined; + widgetModel.viewModel.setViewState(viewState); +} + +/** Render the appropriate input control for a manual value based on the input's metadata. */ +function renderManualInput(widgetModel: BaseWeatherWidgetModel, inputDef: InputDef, value: any) { + const onChange = (val: any) => { + const vs = {...(widgetModel.viewModel.viewState ?? {})}; + vs[inputDef.name] = val; + widgetModel.viewModel.setViewState(vs); + }; + + if (inputDef.enum?.length) { + return buttonGroupInput({ + className: 'weather-v2-settings-form__manual-input', + value: value ?? inputDef.default ?? inputDef.enum[0], + outlined: true, + width: '100%', + onChange, + items: inputDef.enum.map(opt => button({text: opt, value: opt, flex: 1})) + }); + } + + return textInput({ + className: 'weather-v2-settings-form__manual-input', + value: value?.toString() ?? '', + width: '100%', + placeholder: `Enter ${inputDef.name}...`, + onCommit: onChange + }); +} + +/** Format a provider widget label for the source dropdown. */ +function formatProviderLabel(provider: ProviderInfo): string { + const color = getInputWidgetColor(provider.widgetId); + const colorPrefix = color ? '\u25CF ' : ''; + return `${colorPrefix}${provider.widgetTitle}`; +} + +/** Try to extract known options from a config description (e.g., quoted strings). */ +function extractOptionsFromDesc(description?: string): string[] { + const matches = description?.match(/"([^"]+)"/g); + if (!matches || matches.length < 2) return []; + return matches.map(m => m.replace(/"/g, '')); +} diff --git a/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts new file mode 100644 index 000000000..9428c1725 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/WindChartWidget.ts @@ -0,0 +1,197 @@ +import {chart, ChartModel} from '@xh/hoist/cmp/chart'; +import {creates, hoistCmp, LoadSpec, managed, XH} from '@xh/hoist/core'; +import {fmtDate} from '@xh/hoist/format'; +import {computed, makeObservable} from '@xh/hoist/mobx'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {settingsAwarePanel} from './settingsAwarePanel'; +import {widgetRegistry} from '../dash/WidgetRegistry'; +import {convertWind, windUnit} from '../dash/unitUtils'; +import {WidgetMeta} from '../dash/types'; +import {WeatherData} from '../Types'; + +//-------------------------------------------------- +// Model +//-------------------------------------------------- +export class WindChartModel extends BaseWeatherWidgetModel { + static override meta: WidgetMeta = { + id: 'windChart', + title: 'Wind', + description: 'Wind speed and gusts over the forecast period.', + category: 'display', + inputs: [ + { + name: 'city', + type: 'city', + required: true, + default: 'New York', + description: 'City to show wind data for.' + }, + { + name: 'units', + type: 'units', + required: false, + default: 'imperial', + enum: ['imperial', 'metric'], + description: 'Unit system (mph vs m/s).' + } + ], + outputs: [], + config: { + showGusts: {type: 'boolean', default: true}, + chartType: {type: 'enum', enum: ['line', 'area'], default: 'line'}, + hidePanelHeader: { + type: 'boolean', + default: false, + description: 'Hide widget header bar when manual editing is disabled' + } + }, + defaultSize: {w: 6, h: 8}, + minSize: {w: 4, h: 5} + }; + + @managed chartModel: ChartModel; + + constructor() { + super(); + makeObservable(this); + } + + @computed get city(): string { + return this.resolveInput('city') ?? 'New York'; + } + + @computed get units(): string { + return this.resolveInput('units') ?? 'imperial'; + } + + get showGusts(): boolean { + return this.viewModel.viewState?.showGusts ?? true; + } + + get chartType(): string { + return this.viewModel.viewState?.chartType ?? 'line'; + } + + override onLinked() { + super.onLinked(); + this.chartModel = this.createChartModel(); + + this.addReaction({ + track: () => this.city, + run: () => this.loadAsync(), + fireImmediately: true + }); + + this.addReaction({ + track: () => [this.weatherData, this.units, this.showGusts], + run: () => this.updateChart(), + fireImmediately: true + }); + } + + override async doLoadAsync(loadSpec: LoadSpec) { + const {city} = this; + if (!city) return; + try { + await XH.weatherDataService.ensureDataAsync(city, loadSpec); + } catch (e) { + // Handled by WeatherDataService + } + } + + @computed get weatherData(): WeatherData | null { + return XH.weatherDataService.getData(this.city); + } + + private createChartModel(): ChartModel { + return new ChartModel({ + highchartsConfig: { + chart: {zoomType: 'x'}, + title: {text: null}, + xAxis: { + type: 'datetime', + labels: { + formatter: function () { + return fmtDate(this.value, {fmt: 'ddd ha'}); + } + } + }, + yAxis: { + title: {text: null}, + labels: {format: '{value}'} + }, + tooltip: { + shared: true, + xDateFormat: '%A %b %e, %l:%M %p' + }, + legend: {enabled: true}, + credits: {enabled: false} + } + }); + } + + private updateChart() { + const data = this.weatherData; + if (!data?.forecast?.length) return; + + const {forecast} = data, + {units, showGusts, chartType} = this, + wUnit = windUnit(units), + speedData = [], + gustData = []; + + const type = chartType === 'line' ? 'spline' : chartType; + + for (const entry of forecast) { + const speed = Math.round(convertWind(entry.windSpeed, units) * 10) / 10; + speedData.push([entry.dt, speed]); + if (showGusts) { + const gust = entry.windGust + ? Math.round(convertWind(entry.windGust, units) * 10) / 10 + : speed; + gustData.push([entry.dt, gust]); + } + } + + const series: any[] = [ + { + name: 'Wind Speed', + type, + data: speedData, + color: '#7cb5ec', + lineWidth: 2, + marker: {enabled: false}, + tooltip: {valueSuffix: ` ${wUnit}`} + } + ]; + + if (showGusts) { + series.push({ + name: 'Gusts', + type, + data: gustData, + color: '#f45b5b', + lineWidth: 2, + dashStyle: 'ShortDash', + marker: {enabled: false}, + tooltip: {valueSuffix: ` ${wUnit}`} + }); + } + + this.chartModel.setSeries(series); + } +} + +widgetRegistry.register(WindChartModel.meta); + +//-------------------------------------------------- +// Component +//-------------------------------------------------- +export const windChartWidget = hoistCmp.factory({ + displayName: 'WindChartWidget', + model: creates(WindChartModel), + + render({model}) { + return settingsAwarePanel(model, chart()); + } +}); diff --git a/client-app/src/examples/weatherv2/widgets/settingsAwarePanel.ts b/client-app/src/examples/weatherv2/widgets/settingsAwarePanel.ts new file mode 100644 index 000000000..ee938f1a8 --- /dev/null +++ b/client-app/src/examples/weatherv2/widgets/settingsAwarePanel.ts @@ -0,0 +1,34 @@ +import {ReactNode} from 'react'; +import {frame, hframe} from '@xh/hoist/cmp/layout'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {BaseWeatherWidgetModel} from './BaseWeatherWidgetModel'; +import {widgetSettingsForm} from './WidgetSettingsForm'; + +/** + * Wraps widget content in a panel with modal support for settings. + * + * Uses a stable React tree structure (panel > hframe > frame > content) regardless + * of modal state so that content components (charts, grids) are never unmounted + * and remounted when toggling the settings modal. + * + * When modal is inactive, the settings form is simply omitted from the hframe, + * leaving content as the sole child filling 100% of the available space. + * + * Widgets without settings (no PanelModel) return the content unchanged. + */ +export function settingsAwarePanel(model: BaseWeatherWidgetModel, content: ReactNode): ReactNode { + const {panelModel} = model; + if (!panelModel) return content; + + return panel({ + model: panelModel, + flex: 1, + item: hframe({ + flex: 1, + items: [ + frame({flex: 1, item: content}), + panelModel.isModal ? widgetSettingsForm() : null + ] + }) + }); +} diff --git a/grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy b/grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy new file mode 100644 index 000000000..425e521bf --- /dev/null +++ b/grails-app/controllers/io/xh/toolbox/llm/LlmController.groovy @@ -0,0 +1,34 @@ +package io.xh.toolbox.llm + +import io.xh.hoist.security.AccessAll +import io.xh.toolbox.BaseController + +/** + * Controller proxying LLM API requests from the Weather V2 dashboard. + * + * Accepts a JSON body with {systemPrompt, messages} and forwards to + * LlmService for Anthropic API call + rate limiting. + */ +@AccessAll +class LlmController extends BaseController { + + def llmService + + /** + * POST /llm/generate + * Body: {systemPrompt: string, messages: [{role, content}], tools?: [...]} + * Returns: Anthropic API response + */ + def generate() { + def body = parseRequestJSON() + def systemPrompt = body.systemPrompt as String + def messages = body.messages as List + def tools = body.tools as List + + if (!systemPrompt) throw new RuntimeException('systemPrompt is required.') + if (!messages) throw new RuntimeException('messages array is required.') + + def username = authUsername + renderJSON(llmService.generate(systemPrompt, messages, username, tools)) + } +} diff --git a/grails-app/services/io/xh/toolbox/llm/LlmService.groovy b/grails-app/services/io/xh/toolbox/llm/LlmService.groovy new file mode 100644 index 000000000..7f05cbf52 --- /dev/null +++ b/grails-app/services/io/xh/toolbox/llm/LlmService.groovy @@ -0,0 +1,127 @@ +package io.xh.toolbox.llm + +import io.xh.hoist.BaseService +import io.xh.hoist.config.ConfigService +import io.xh.hoist.exception.DataNotAvailableException +import io.xh.hoist.http.JSONClient +import io.xh.hoist.json.JSONSerializer +import org.apache.hc.client5.http.classic.methods.HttpPost +import org.apache.hc.core5.http.io.entity.StringEntity + +/** + * Proxy service for LLM API calls. + * + * Proxies requests to the Anthropic Messages API. The API key is stored in a + * Hoist config entry ('llmApiKey') so it never reaches the client. Supports + * basic per-user rate limiting via an in-memory counter map. + * + * Config entries: + * - llmApiKey (string/pwd): Anthropic API key. + * - llmModel (string): Model identifier, default 'claude-sonnet-4-6'. + * - llmMaxTokens (int): Max response tokens, default 4096. + * - llmRateLimit (int): Max requests per user per hour, default 20. + */ +class LlmService extends BaseService { + + static clearCachesConfigs = ['llmApiKey', 'llmModel', 'llmMaxTokens', 'llmRateLimit'] + + ConfigService configService + + // Simple per-user rate limiter: username → list of timestamps + private Map> _rateLimitMap = [:].withDefault { [] } + + private JSONClient _jsonClient + private JSONClient getClient() { + _jsonClient ?= new JSONClient() + } + + /** + * Generate a response from the LLM. + * + * @param systemPrompt - system message for the LLM + * @param messages - conversation messages [{role: 'user'|'assistant', content: '...'}] + * @param username - for rate limiting + * @param tools - optional list of tool definitions for function calling + * @return parsed response map from the Anthropic API + */ + Map generate(String systemPrompt, List messages, String username, List tools = null) { + checkApiKey() + checkRateLimit(username) + + def model = configService.getString('llmModel', 'claude-sonnet-4-6'), + maxTokens = configService.getInt('llmMaxTokens', 8192), + apiKey = configService.getPwd('llmApiKey') + + def body = [ + model : model, + max_tokens: maxTokens, + system : systemPrompt, + messages : messages + ] + + if (tools) { + body.tools = tools + } + + def serialized = JSONSerializer.serialize(body) + + def post = new HttpPost('https://api.anthropic.com/v1/messages') + post.setHeader('Content-Type', 'application/json') + post.setHeader('x-api-key', apiKey) + post.setHeader('anthropic-version', '2023-06-01') + post.setEntity(new StringEntity(serialized)) + + try { + def response = client.executeAsMap(post) + recordRequest(username) + return response + } catch (Exception e) { + logError("LLM API call failed", e) + throw new RuntimeException("LLM API call failed: ${e.message}") + } + } + + //-------------------------------------------------- + // Rate limiting + //-------------------------------------------------- + private void checkRateLimit(String username) { + def limit = configService.getInt('llmRateLimit', 20), + now = System.currentTimeMillis(), + oneHourAgo = now - 3600_000 + + // Prune old entries + def userRequests = _rateLimitMap[username] + userRequests.removeAll { it < oneHourAgo } + + if (userRequests.size() >= limit) { + throw new RuntimeException( + "Rate limit exceeded. Max ${limit} requests per hour. Please wait and try again." + ) + } + } + + private void recordRequest(String username) { + _rateLimitMap[username] << System.currentTimeMillis() + } + + private void checkApiKey() { + def key = configService.getPwd('llmApiKey', 'none') + if (key == 'none' || !key?.trim()) { + throw new DataNotAvailableException( + 'LLM API key not configured. Set the "llmApiKey" config entry in the Hoist Admin console.' + ) + } + } + + @Override + void clearCaches() { + super.clearCaches() + _jsonClient = null + _rateLimitMap.clear() + } + + @Override + Map getAdminStats() {[ + config: configForAdminStats('llmApiKey', 'llmModel', 'llmMaxTokens', 'llmRateLimit') + ]} +}