| Server IP : 3.147.158.171 / Your IP : 216.73.216.229 Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.21 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tsai/repo/api/ |
Upload File : |
"""
Widget Creation Script
Creates a widget on a site by calling the local FastAPI endpoint.
A widget is a Drupal `node--widget` whose entire configuration lives in a JSON
`config` object (mirroring the Creator app's widget form / `createDefaultWidgetConfig`).
This script starts from that default config, applies the named CLI options, then
deep-merges any `--config-file` / `--config-json` overrides on top — which is how
the ~25 feature toggles and nested groups are set without a flag per field.
Usage:
python create_widget.py <site> --title <title> --subtitle <subtitle> \
--description <desc> --help-text <help> --icon <icon> [options]
Arguments:
site Site slug (e.g., jpt, tsai, sandbox). Optional
(and ignored) when --system-wide is given.
Required card fields:
--title <text> Widget title (used as the Drupal node title)
--subtitle <text> Subtitle / category label
--description <text> Dashboard/detail description
--help-text <text> Help text shown to users
--icon <name> Material Design icon name (e.g. "auto_awesome")
Options:
--env <env> Environment (default: TSAI_ENV or development)
--id <slug> Widget slug (card.id; default: slugify(title))
--message <text> Initial user message example
--message-hint <text> Input hint (default: "Enter your message")
--instructions <text> AI system instructions (default: helpful assistant)
--enabled / --disabled Whether the widget is enabled (default: enabled)
--hidden Hide the widget from the UI (default: visible)
--shared / --no-shared Allow other sites to import (default: shared)
--system-wide Create a platform-wide widget owned by the `fapi`
Drupal user instead of a site. It shows on every
site's dashboard (Widget.systemWide == true) and the
`site` argument is not needed. Requires
FAPI_DRUPAL_UUID in the API .env.
--provider <provider> OpenAI or Claude (default: Claude)
--model-name <name> Model name (default: claude-sonnet-4-6)
--temperature <t> Temperature (default: 0.7)
--max-tokens <n> Max tokens (default: 4096)
--no-stream Disable streaming responses (default: streaming on)
JSON overrides (deep-merged over the config, in this order, after named args):
--config-file <path> Path to a JSON file with a partial/full WidgetConfig
--config-json '<json>' Inline JSON with a partial/full WidgetConfig
Examples:
python create_widget.py jpt --title "Unit Converter" --subtitle "Tools" \
--description "Convert between units" --help-text "Ask me to convert anything" \
--icon "straighten"
python create_widget.py jpt --title "Story Helper" --subtitle "Writing" \
--description "..." --help-text "..." --icon "auto_stories" \
--config-json '{"features":{"save":true,"autoSend":{"enabled":true,"intro":"Hi"}}}'
python create_widget.py --system-wide --title "Word Guess" --subtitle "Games" \
--description "Wordle-style game" --help-text "Guess the word" --icon "grid_view"
"""
import argparse
import asyncio
import json
import os
import re
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent / '.env')
import requests
def default_widget_config() -> dict:
"""Return the default WidgetConfig.
Mirrors `createDefaultWidgetConfig()` in
chatbot/projects/tsai/src/lib/services/utils-service.ts and the `card` block
built in widget-form.ts. Removed fields (animation, features.imageCount,
features.imageSize) are intentionally absent.
"""
return {
"endpoint": "unknown",
"shared": True,
"messageHint": "Enter your message",
"message": "",
"messageType": "Chat Message",
"instructions": "You are a helpful assistant.",
"card": {
"id": "",
"title": "",
"subtitle": "",
"description": "",
"help": "",
"icon": "",
"quickLinks": [],
"link": {"name": "Action Link Title", "href": "#"},
"weight": 10,
"enabled": True,
"hidden": False,
"dynamic": True,
},
"features": {
"prompt": True,
"new": True,
"save": False,
"clear": False,
"share": False,
"send": True,
"enhance": False,
"refresh": False,
"uploadImages": False,
"uploadImagesImageSize": False,
"downloadImages": False,
"copyToClipboard": True,
"copyToPrompt": True,
"saveImagePrompt": False,
"createImageFromText": False,
"autoSend": {"enabled": False, "addToViewer": False, "addUserName": False},
"prompts": {"enabled": False, "imagePrompt": False},
# LangChain routing + streaming are both required for the multi-provider
# (Claude + OpenAI) chat path and the runtime model picker. The plain /chat
# path is OpenAI-only, so chat widgets default to LangChain. See
# messenger-base.ts sendMessage (streamedResponses gate) / sendStreamedMessage
# (useLangChain gate) and responses_langchain.py.
"streamedResponses": True,
"useLangChain": True,
"useStructuredOutput": False,
"modelSelection": True,
"useAgentTools": False,
},
"model": {
"provider": "Claude",
"name": "claude-sonnet-4-6",
"temperature": 0.7,
"maxTokens": 16384,
"stream": True,
},
}
def deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge `override` into `base` (mutating and returning base).
Nested dicts are merged key-by-key; scalars and lists replace wholesale.
"""
for key, value in override.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
deep_merge(base[key], value)
else:
base[key] = value
return base
def load_site_config(site_slug: str) -> str:
"""Return cb_host for the given site slug."""
from app.services.site_config_service import SiteConfigService
service = SiteConfigService()
site = service.get_site_by_slug(site_slug)
if site:
return site.cb_host
all_slugs = sorted(s.slug for s in service.get_all_sites())
print(f"Error: site '{site_slug}' not found")
print(f"Available sites: {', '.join(all_slugs)}")
sys.exit(1)
def create_systemwide_widget(config: dict, shared: bool):
"""Create a platform-wide widget node owned by the `fapi` Drupal user.
The FastAPI `/cms/widget/` endpoint always assigns ownership to the site
resolved from the Origin header, so a system-wide widget can't be made that
way. Instead we create the node--widget directly against Drupal's JSON:API,
setting the uid relationship to FAPI_DRUPAL_UUID. Such a node is what the
backend reports as `Widget.systemWide == true` (drupal.py::create_widget).
"""
from app.services.drupal import DrupalService
drupal_host = os.getenv("DRUPAL_HOST")
drupal_api = os.getenv("DRUPAL_API")
fapi_uuid = os.getenv("FAPI_DRUPAL_UUID")
if not fapi_uuid:
print("Error: FAPI_DRUPAL_UUID is not set in the API .env; cannot create a "
"system-wide widget.")
sys.exit(1)
async def _create():
service = DrupalService()
session = requests.Session()
try:
login_data = await service.login(session)
logout_token = login_data.get("logout_token")
csrf_token = await service.get_csrf_token(session)
headers = {
"Content-Type": "application/vnd.api+json",
"X-CSRF-Token": csrf_token,
"Authorization": f"Bearer {csrf_token}",
}
node_data = {
"data": {
"type": "node--widget",
"attributes": {
"title": config.get("card", {}).get("title", "widget"),
"field_shared2": shared,
"body": {"value": json.dumps(config), "format": "plain_text"},
},
"relationships": {
"uid": {"data": {"type": "user--user", "id": fapi_uuid}}
},
}
}
url = f"{drupal_host}{drupal_api}/node/widget"
resp = session.post(url, headers=headers, json=node_data)
await service.logout(session, logout_token)
return resp
finally:
session.close()
return asyncio.run(_create())
def main():
parser = argparse.ArgumentParser(description="Create a widget via the FastAPI endpoint")
parser.add_argument("site", nargs="?", default=None,
help="Site slug (e.g., jpt, tsai). Optional/ignored with --system-wide.")
# Required card fields
parser.add_argument("--title", required=True, help="Widget title (Drupal node title)")
parser.add_argument("--subtitle", required=True, help="Subtitle / category label")
parser.add_argument("--description", required=True, help="Dashboard/detail description")
parser.add_argument("--help-text", dest="help_text", required=True, help="Help text shown to users")
parser.add_argument("--icon", required=True, help="Material Design icon name (e.g. auto_awesome)")
# Optional card / message
parser.add_argument("--id", default=None, help="Widget slug for card.id (default: slugify(title))")
parser.add_argument("--message", default=None, help="Initial user message example")
parser.add_argument("--message-hint", default=None, help="Input hint (default: 'Enter your message')")
parser.add_argument("--instructions", default=None, help="AI system instructions")
enabled_group = parser.add_mutually_exclusive_group()
enabled_group.add_argument("--enabled", dest="enabled", action="store_true", default=True,
help="Create the widget as enabled (default)")
enabled_group.add_argument("--disabled", dest="enabled", action="store_false",
help="Create the widget as disabled")
parser.add_argument("--hidden", action="store_true", help="Hide the widget from the UI")
shared_group = parser.add_mutually_exclusive_group()
shared_group.add_argument("--shared", dest="shared", action="store_true", default=True,
help="Allow other sites to import this widget (default)")
shared_group.add_argument("--no-shared", dest="shared", action="store_false",
help="Do not share this widget with other sites")
parser.add_argument("--system-wide", dest="system_wide", action="store_true",
help="Create a platform-wide widget owned by the `fapi` Drupal "
"user (shows on every site; no `site` arg needed)")
# Model
parser.add_argument("--provider", default="Claude", choices=["OpenAI", "Claude"],
help="LLM provider (default: Claude)")
parser.add_argument("--model-name", default="claude-sonnet-4-6",
help="Model name (default: claude-sonnet-4-6)")
parser.add_argument("--temperature", type=float, default=0.7, help="Temperature (default: 0.7)")
parser.add_argument("--max-tokens", type=int, default=16384, help="Max tokens (default: 16384)")
parser.add_argument("--no-stream", dest="stream", action="store_false", default=True,
help="Disable streaming responses (default: streaming on)")
parser.add_argument("--env", default=os.getenv("TSAI_ENV", "development"),
choices=["development", "production", "scike_ai"],
help="Environment (default: TSAI_ENV or development)")
# JSON overrides
parser.add_argument("--config-file", default=None,
help="Path to a JSON file with a partial/full WidgetConfig to deep-merge")
parser.add_argument("--config-json", default=None,
help="Inline JSON with a partial/full WidgetConfig to deep-merge")
args = parser.parse_args()
slug = args.id or re.sub(r'[^a-z0-9]+', '-', args.title.lower()).strip('-')
os.environ['TSAI_ENV'] = args.env
# System-wide widgets are owned by `fapi`, not a site, so no Origin/cb_host
# lookup is needed. A site-owned widget requires a valid site slug.
cb_host = None
if not args.system_wide:
if not args.site:
print("Error: a site slug is required unless --system-wide is given.")
sys.exit(1)
# Look up cb_host (used as Origin header for site auth)
cb_host = load_site_config(args.site)
# 1. Start from defaults
config = default_widget_config()
# 2. Apply named args
config["shared"] = args.shared
if args.message is not None:
config["message"] = args.message
if args.message_hint is not None:
config["messageHint"] = args.message_hint
if args.instructions is not None:
config["instructions"] = args.instructions
config["card"].update({
"id": slug,
"title": args.title,
"subtitle": args.subtitle,
"description": args.description,
"help": args.help_text,
"icon": args.icon,
"enabled": args.enabled,
"hidden": args.hidden,
})
config["model"].update({
"provider": args.provider,
"name": args.model_name,
"temperature": args.temperature,
"maxTokens": args.max_tokens,
"stream": args.stream,
})
# 3. Deep-merge --config-file then --config-json over the config
if args.config_file:
try:
with open(args.config_file) as f:
file_override = json.load(f)
except (OSError, json.JSONDecodeError) as e:
print(f"Error reading --config-file '{args.config_file}': {e}")
sys.exit(1)
deep_merge(config, file_override)
if args.config_json:
try:
json_override = json.loads(args.config_json)
except json.JSONDecodeError as e:
print(f"Error parsing --config-json: {e}")
sys.exit(1)
deep_merge(config, json_override)
# Keep the node title / mirror fields in sync with the (possibly overridden) card
card = config.get("card", {})
owner = "fapi (system-wide)" if args.system_wide else f"site '{args.site}'"
print(f"Creating widget '{args.title}' for {owner}...")
print(f" Slug: {slug}")
print(f" Subtitle: {args.subtitle}, Icon: {args.icon}")
print(f" Provider: {config['model']['provider']}, Model: {config['model']['name']}")
print(f" Enabled: {card.get('enabled')}, Hidden: {card.get('hidden')}, Shared: {config.get('shared')}")
if args.system_wide:
# Create the node directly against Drupal with `fapi` ownership.
try:
response = create_systemwide_widget(config, config.get("shared", args.shared))
except requests.ConnectionError:
print("Error: Could not connect to Drupal (check DRUPAL_HOST in the API .env).")
sys.exit(1)
if response.status_code == 201:
print("System-wide widget created successfully!")
data = response.json()
node_id = data.get("data", {}).get("id")
if node_id:
print(f"Storage ID (Drupal node): {node_id}")
else:
print(f"Error {response.status_code}: {response.text}")
sys.exit(1)
return
body = {
"widget": {
"config": config,
"shared": config.get("shared", args.shared),
"name": card.get("title", args.title),
"id": card.get("id", slug),
"category": card.get("subtitle", args.subtitle),
"enabled": card.get("enabled", args.enabled),
"hidden": card.get("hidden", args.hidden),
}
}
url = "http://127.0.0.1:8000/api/v1/cms/widget/"
headers = {
"Origin": f"https://{cb_host}",
"Content-Type": "application/json"
}
try:
response = requests.post(url, json=body, headers=headers, timeout=30)
except requests.ConnectionError:
print("Error: Could not connect to FastAPI server at http://127.0.0.1:8000")
print("Make sure the server is running: cd api && uvicorn app.main:app --reload")
sys.exit(1)
if response.status_code in (200, 201):
print("Widget created successfully!")
data = response.json()
print(json.dumps(data, indent=2))
# Drupal JSON:API node UUID (the widget's storageId).
node_id = data.get("data", {}).get("data", {}).get("id")
if node_id:
print(f"Storage ID (Drupal node): {node_id}")
else:
print(f"Error {response.status_code}: {response.text}")
sys.exit(1)
if __name__ == "__main__":
main()