[eric] app builder cleanup — remove Auto Run, Test Input, RenderOutput tool + Outputs activation gate

This commit is contained in:
ciregenz
2026-05-11 09:05:45 -07:00
parent 3ff586db52
commit d20ff7d16c
20 changed files with 29 additions and 1727 deletions
+9 -84
View File
@@ -14,7 +14,6 @@ from backend.apps.agents.models import (
)
from backend.apps.agents.ws_manager import ws_manager
from backend.apps.modes.modes import load_mode
from backend.apps.outputs.outputs import _load_all as load_all_outputs
from backend.apps.settings.settings import load_settings
from backend.apps.tools_lib.tools_lib import (
_load_all as load_all_tools,
@@ -188,7 +187,6 @@ FULL_TOOLS = [
"EnterPlanMode", "ExitPlanMode", "EnterWorktree",
"TaskOutput", "TaskStop",
"CronCreate", "CronList", "CronDelete",
"RenderOutput",
"InvokeAgent",
"Agent",
# ToolSearch is the loader the CLI uses to expose deferred tool schemas
@@ -530,55 +528,6 @@ class AgentManager:
+ "\n</connected_mcp_tools>"
)
def _build_outputs_context(self, active_outputs: list[str] | None = None) -> str | None:
"""Outputs context for the system prompt.
Two-mode emission gated by session.active_outputs:
- Cheap one-line index for ALL Outputs (name + id + description)
so the model can OutputSearch / OutputActivate against them.
- FULL input_schema only for the ids in active_outputs. Defaults
to empty: nothing ships full-schema until the model has
explicitly activated the Output.
This drops typical 30-Output context from ~30KB to ~2KB at
steady state; an active Output adds ~1KB back per id.
"""
import json as _json
all_outputs = load_all_outputs()
if not all_outputs:
return None
active_set = set(active_outputs or [])
index_lines = []
full_schemas: list[str] = []
for out in all_outputs:
desc = f"{out.description}" if out.description else ""
marker = " [active]" if out.id in active_set else ""
index_lines.append(f"- `{out.id}` **{out.name}**{desc}{marker}")
if out.id in active_set:
schema_str = _json.dumps(out.input_schema, indent=2)
full_schemas.append(
f"### `{out.id}` ({out.name})\n```json\n{schema_str}\n```"
)
sections = ["<available_views>"]
sections.append(
"The following reusable View artifacts are available. The model "
"must call OutputActivate(output_id) before RenderOutput so that "
"the schema is in context — otherwise RenderOutput input_data may "
"be malformed. Activated Outputs appear under <activated_view_schemas> "
"below."
)
sections.append("")
sections.extend(index_lines)
sections.append("</available_views>")
if full_schemas:
sections.append("")
sections.append("<activated_view_schemas>")
sections.extend(full_schemas)
sections.append("</activated_view_schemas>")
return "\n".join(sections)
def _build_browser_context(self, dashboard_id: str | None, selected_browser_ids: list[str] | None = None) -> str | None:
"""Build a context block listing browser cards and delegation instructions.
@@ -736,8 +685,8 @@ class AgentManager:
sections.append("</mcp_servers>")
return "\n".join(sections)
def _compose_system_prompt(self, default_prompt: str | None, mode_prompt: str | None, session_prompt: str | None, connected_tools_ctx: str | None = None, outputs_ctx: str | None = None, browser_ctx: str | None = None, mcp_registry_ctx: str | None = None) -> str | None:
parts = [p for p in (default_prompt, mode_prompt, session_prompt, connected_tools_ctx, mcp_registry_ctx, outputs_ctx, browser_ctx) if p]
def _compose_system_prompt(self, default_prompt: str | None, mode_prompt: str | None, session_prompt: str | None, connected_tools_ctx: str | None = None, browser_ctx: str | None = None, mcp_registry_ctx: str | None = None) -> str | None:
parts = [p for p in (default_prompt, mode_prompt, session_prompt, connected_tools_ctx, mcp_registry_ctx, browser_ctx) if p]
return "\n\n".join(parts) if parts else None
async def launch_agent(self, config: AgentConfig) -> AgentSession:
@@ -963,7 +912,7 @@ class AgentManager:
# - compact_threshold_pct (default 0.65): summarize stale tool_results
# and old user/assistant pairs before the next query() call
# - context_soft_cap_pct (default 0.90): pre-send hard guard. After
# compaction, if still over, LRU-trim active_outputs/active_mcps
# compaction, if still over, LRU-trim active_mcps
# - >= 1.0 hits the proxy/Anthropic 200K ceiling — friendly card
# surfaces from the catch-all
# ------------------------------------------------------------------
@@ -1411,9 +1360,9 @@ class AgentManager:
sub_session_id = uuid4().hex
sub_name = agent_prompt[:50] if agent_prompt else "Sub-agent"
# Subagent context isolation invariant (Phase 3, Layer P):
# children DO NOT inherit the parent's active_mcps,
# active_outputs, or compaction state. They start with the
# AgentSession defaults (empty lists). Reasoning:
# children DO NOT inherit the parent's active_mcps or
# compaction state. They start with the AgentSession
# defaults (empty lists). Reasoning:
# - Security: a parent that activated Gmail shouldn't
# leak Gmail tools to a subagent doing an unrelated
# task. The user only approved Gmail for the parent.
@@ -1441,11 +1390,10 @@ class AgentManager:
],
dashboard_id=session.dashboard_id,
parent_session_id=session_id,
# Explicit empty lists (matches the model defaults) so
# Explicit empty list (matches the model default) so
# the invariant is visible at the spawn site rather
# than relying on the field's default_factory.
active_mcps=[],
active_outputs=[],
)
self.sessions[sub_session_id] = sub_session
await ws_manager.broadcast_global("agent:status", {
@@ -1496,7 +1444,6 @@ class AgentManager:
# tool-call layer instead — prompt rules are not a security
# boundary.
connected_tools_ctx = None
outputs_ctx = self._build_outputs_context(session.active_outputs)
browser_ctx = self._build_browser_context(session.dashboard_id, selected_browser_ids=selected_browser_ids)
# Reconcile active_mcps against currently-enabled tools (Phase 3).
@@ -1530,7 +1477,6 @@ class AgentManager:
mode_sys_prompt,
session.system_prompt,
connected_tools_ctx,
outputs_ctx,
browser_ctx,
mcp_registry_ctx,
)
@@ -1630,24 +1576,6 @@ class AgentManager:
"type": "stdio",
}
# Outputs/Views activation gate (Phase 2). Same shape as the
# MCP meta-server but for Outputs. The model only sees a
# one-line index of available Outputs in the system prompt
# (see _build_outputs_context); to load any specific
# Output's full input_schema, it must call OutputActivate.
outputs_meta_server_path = os.path.join(
os.path.dirname(__file__), "outputs_meta_server.py"
)
mcp_servers["openswarm-outputs-meta"] = {
"command": sys.executable,
"args": [outputs_meta_server_path],
"env": {
"OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
"OPENSWARM_AUTH_TOKEN": _get_auth_token3(),
"OPENSWARM_PARENT_SESSION_ID": session.id,
},
"type": "stdio",
}
# The CLI's built-in WebSearch/WebFetch wraps Anthropic's
# web_search_20250305. For non-Claude primaries the CLI
@@ -2260,8 +2188,8 @@ class AgentManager:
# Pre-send hard guard (Phase 2). After compaction, if the
# session is still over context_soft_cap_pct of the window,
# LRU-trim oldest active_outputs then active_mcps. Stops the
# 429 from ever firing on predictable overflow paths.
# LRU-trim oldest active_mcps. Stops the 429 from ever
# firing on predictable overflow paths.
try:
# Use the most recent measurement (the prior turn's
# input_tokens) as the estimate. Conservative because the
@@ -2272,9 +2200,6 @@ class AgentManager:
_hard_cap = int(session.context_window * session.context_soft_cap_pct)
if _est_tokens >= _hard_cap:
trimmed: list[str] = []
while _est_tokens >= _hard_cap and session.active_outputs:
trimmed.append(f"output:{session.active_outputs.pop(0)}")
_est_tokens -= 5_000 # rough per-Output schema cost
while _est_tokens >= _hard_cap and len(session.active_mcps) > 1:
# Keep at least one MCP active so the model can
# finish whatever it was doing; trim from oldest
+2 -9
View File
@@ -155,12 +155,6 @@ class AgentSession(BaseModel):
# filter lives at the dispatch layer (mcp_servers passed to the SDK),
# not the prompt layer.
active_mcps: list[str] = Field(default_factory=list)
# Output ids the model has activated this session via the
# OutputActivate meta-tool. Empty by default — _build_outputs_context
# only emits the cheap one-line index for unactivated outputs; full
# input_schema is shipped only for the ids in this list. Same gate
# pattern as active_mcps but for the Outputs/Views surface.
active_outputs: list[str] = Field(default_factory=list)
# Estimated framework preamble tokens (preset + tool defs + MCP descs +
# composed prompt). Subtracted from displayed input for honest "this turn"
# numbers. Heuristic; clamped >= 0.
@@ -177,9 +171,8 @@ class AgentSession(BaseModel):
# Pre-send hard guard. Fires later than the compaction threshold —
# 0.90 of 200K = 180K — to give the auto-compact path a chance to
# bring the request back under the ceiling. If still over after
# compaction, LRU-trim the oldest active_outputs / active_mcps. Past
# this we surface the friendly context-overflow card instead of
# letting a 429 hit.
# compaction, LRU-trim the oldest active_mcps. Past this we surface
# the friendly context-overflow card instead of letting a 429 hit.
context_soft_cap_pct: float = 0.90
context_window: int = 200_000
# How much the model should "think" before answering. Provider-agnostic
-250
View File
@@ -1,250 +0,0 @@
#!/usr/bin/env python3
"""Stdio MCP server exposing the Outputs (Views) activation gate.
Same shape as mcp_meta_server.py but for the Outputs surface. The model
sees a one-line index of all available Outputs in the system prompt; to
get the full input_schema for a specific Output (so it can call
RenderOutput correctly), it must call OutputActivate first. The full
schema is then injected on the next turn.
Tools:
- OutputList: enumerate all Outputs (active + available).
- OutputSearch(query): rank by name/description match + use_count.
- OutputActivate(output_id): pin the Output's schema into context.
Same security/anti-hallucination guarantees as mcp_meta_server: input
validated against the canonical store, unknown ids return the valid
options instead of activating.
"""
import json
import os
import sys
import urllib.error
import urllib.request
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/outputs-meta"
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
TOOLS = [
{
"name": "OutputList",
"description": (
"List all reusable View artifacts (Outputs) installed on this "
"machine. Returns one entry per Output with id, name, "
"description, and activation status. The full input_schema "
"is NOT included — call OutputActivate to load it. Use this "
"for a broad survey before picking one."
),
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "OutputSearch",
"description": (
"Find Outputs relevant to a query. Ranks by name/description "
"match plus recent-use frequency. Returns the top matches "
"without their schemas. Call OutputActivate after picking one."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Free-form description of what you want to render (e.g. 'inbox dashboard', 'sales chart').",
},
},
"required": ["query"],
"additionalProperties": False,
},
},
{
"name": "OutputActivate",
"description": (
"Activate an Output for this session — pins its full input_schema "
"into context starting next turn so RenderOutput can validate "
"the input_data shape. Validate the id by calling OutputList or "
"OutputSearch first; invalid ids return the valid options "
"instead of activating."
),
"inputSchema": {
"type": "object",
"properties": {
"output_id": {
"type": "string",
"description": "Output id as returned by OutputList/OutputSearch.",
},
"reason": {
"type": "string",
"description": "One-sentence explanation of why this Output is needed for the user's task.",
},
},
"required": ["output_id"],
"additionalProperties": False,
},
},
]
def send_response(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def call_backend(action: str, payload: dict) -> dict:
full = {**payload, "parent_session_id": PARENT_SESSION_ID}
body = json.dumps(full).encode()
headers = {"Content-Type": "application/json"}
if BACKEND_AUTH:
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
req = urllib.request.Request(
f"{BACKEND_URL}/{action}",
data=body,
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else str(e)
return {"error": f"HTTP {e.code}: {body}"}
except Exception as e:
return {"error": str(e)}
def format_outputs(outputs: list[dict], heading: str = "") -> str:
if not outputs:
return ""
lines = []
if heading:
lines.append(heading)
for o in outputs:
oid = o.get("id", "")
name = o.get("name", "")
desc = o.get("description", "") or "no description"
status = o.get("status", "available")
used = o.get("use_count", 0)
used_hint = f" (used {used}×)" if used else ""
lines.append(f"- `{oid}` **{name}** [{status}]{used_hint}{desc}")
return "\n".join(lines)
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
if tool_name == "OutputList":
result = call_backend("list", {})
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
active = result.get("active", [])
available = result.get("available", [])
if not active and not available:
return {"content": [{"type": "text", "text": "No Outputs / Views are defined yet. Use the App Builder mode to create one."}]}
parts = []
if active:
parts.append(format_outputs(active, "Active (full schema in context, RenderOutput can use these now):"))
if available:
parts.append(format_outputs(available, "Available (call OutputActivate to load schema):"))
return {"content": [{"type": "text", "text": "\n\n".join(parts)}]}
if tool_name == "OutputSearch":
query = arguments.get("query", "")
if not query:
return {"content": [{"type": "text", "text": "Error: query is required"}], "isError": True}
result = call_backend("search", {"query": query})
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
matches = result.get("matches", [])
if not matches:
return {"content": [{"type": "text", "text": f"No Outputs matched '{query}'. Try OutputList to see everything available."}]}
body = format_outputs(matches, f"Top matches for '{query}':")
body += "\n\nNext step: call OutputActivate(output_id) to pin the schema."
return {"content": [{"type": "text", "text": body}]}
if tool_name == "OutputActivate":
output_id = arguments.get("output_id", "")
reason = arguments.get("reason", "")
if not output_id:
return {"content": [{"type": "text", "text": "Error: output_id is required"}], "isError": True}
result = call_backend("activate", {"output_id": output_id, "reason": reason})
if "error" in result:
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
if result.get("status") == "unknown_output":
available = result.get("available", [])
return {
"content": [{
"type": "text",
"text": (
f"Unknown Output id '{output_id}'. Valid options: "
+ ", ".join(f"`{o}`" for o in available)
+ ". Call OutputList for full descriptions."
),
}],
"isError": True,
}
if result.get("status") == "already_active":
return {"content": [{"type": "text", "text": f"`{output_id}` is already active for this session — its schema is in context now, RenderOutput can use it."}]}
if result.get("status") == "activated":
return {
"content": [{
"type": "text",
"text": (
f"Activated Output `{output_id}`. Its full input_schema "
f"will be in context on the NEXT turn. End this turn now "
f"and call RenderOutput with the activated id."
),
}],
}
return {"content": [{"type": "text", "text": f"Unexpected response: {json.dumps(result)}"}], "isError": True}
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
method = msg.get("method")
id_ = msg.get("id")
params = msg.get("params", {})
if method == "initialize":
send_response(id_, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "openswarm-outputs-meta", "version": "1.0.0"},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
send_response(id_, {"tools": TOOLS})
elif method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
try:
result = handle_tool_call(tool_name, arguments)
send_response(id_, result)
except Exception as e:
send_response(id_, error={"code": -32000, "message": str(e)})
elif method == "resources/list":
send_response(id_, {"resources": []})
elif method == "prompts/list":
send_response(id_, {"prompts": []})
elif id_ is not None:
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
-40
View File
@@ -4,15 +4,6 @@ from uuid import uuid4
from datetime import datetime
class AutoRunConfig(BaseModel):
enabled: bool = False
prompt: str = ""
context_paths: list[dict[str, str]] = Field(default_factory=list)
forced_tools: list[dict[str, Any]] = Field(default_factory=list)
mode: str = "agent"
model: str = "sonnet"
class Output(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
name: str
@@ -25,7 +16,6 @@ class Output(BaseModel):
})
files: dict[str, str] = Field(default_factory=dict)
permission: str = "ask"
auto_run_config: Optional[AutoRunConfig] = None
thumbnail: Optional[str] = None
# Linkage so reopening the App Builder reattaches to the in-progress session
# and reuses the same on-disk workspace folder instead of seeding a fresh one
@@ -34,11 +24,6 @@ class Output(BaseModel):
workspace_id: Optional[str] = None
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
# Usage stats: bumped by RenderOutput dispatch + OutputActivate. Drives
# ranking in OutputSearch so frequently-used Outputs surface first.
# Both default to absent for backward compat with old on-disk records.
last_used_at: Optional[str] = None
use_count: int = 0
@model_validator(mode="before")
@classmethod
@@ -79,7 +64,6 @@ class OutputCreate(BaseModel):
"required": [],
})
files: dict[str, str] = Field(default_factory=dict)
auto_run_config: Optional[dict[str, Any]] = None
thumbnail: Optional[str] = None
session_id: Optional[str] = None
workspace_id: Optional[str] = None
@@ -111,7 +95,6 @@ class OutputUpdate(BaseModel):
input_schema: Optional[dict[str, Any]] = None
files: Optional[dict[str, str]] = None
permission: Optional[str] = None
auto_run_config: Optional[dict[str, Any]] = None
thumbnail: Optional[str] = None
session_id: Optional[str] = None
workspace_id: Optional[str] = None
@@ -153,29 +136,6 @@ class OutputExecuteResult(BaseModel):
error: Optional[str] = None
class AutoRunRequest(BaseModel):
# extra="ignore" so callers that historically sent `backend_code` (the
# field was removed for security — see auto_run_output endpoint) don't
# 422 on the way in. The endpoint silently drops it now; backend code
# only runs via the persisted-Output flow at /api/outputs/execute.
model_config = {"extra": "ignore"}
prompt: str
input_schema: dict[str, Any] = Field(default_factory=dict)
context_paths: list[dict[str, str]] = Field(default_factory=list)
forced_tools: list[str] = Field(default_factory=list)
model: str = "sonnet"
class AutoRunAgentRequest(BaseModel):
prompt: str
input_schema: dict[str, Any] = Field(default_factory=dict)
output_id: str
model: str = "sonnet"
forced_tools: list[str] = Field(default_factory=list)
context_paths: list[dict[str, str]] = Field(default_factory=list)
class WorkspaceSeedRequest(BaseModel):
workspace_id: str
files: Optional[dict[str, str]] = None
+1 -156
View File
@@ -13,8 +13,7 @@ from jsonschema import validate as schema_validate, ValidationError as SchemaVal
from backend.config.Apps import SubApp
from backend.apps.outputs.models import (
Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult,
VibeCodeRequest, AutoRunRequest, AutoRunConfig, AutoRunAgentRequest,
WorkspaceSeedRequest,
VibeCodeRequest, WorkspaceSeedRequest,
)
from backend.apps.outputs.executor import execute_backend_code
from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL, VIEW_TEMPLATE_FILES
@@ -367,7 +366,6 @@ async def create_output(body: OutputCreate):
icon=body.icon,
input_schema=body.input_schema,
files=body.files,
auto_run_config=body.auto_run_config,
thumbnail=body.thumbnail,
created_at=now,
updated_at=now,
@@ -381,8 +379,6 @@ async def create_output(body: OutputCreate):
async def update_output(output_id: str, body: OutputUpdate):
output = _load(output_id)
for k, v in body.model_dump(exclude_none=True).items():
if k == "auto_run_config" and isinstance(v, dict):
v = AutoRunConfig(**v)
setattr(output, k, v)
output.updated_at = datetime.now().isoformat()
_save(output)
@@ -503,88 +499,6 @@ async def vibe_code(body: VibeCodeRequest):
}
AUTO_RUN_SYSTEM_PROMPT = """\
You generate structured JSON data matching a given schema.
The user provides a prompt describing what data to generate and a JSON Schema.
Return ONLY valid JSON that conforms to the schema. No markdown fences, no extra text, no explanation.
Every required field must be present. Use realistic, meaningful data.\
"""
@outputs.router.post("/auto-run")
async def auto_run_output(body: AutoRunRequest):
"""Use an LLM to generate input data matching the schema, then optionally execute backend code."""
try:
import anthropic
except ImportError:
return {"error": "anthropic SDK not installed", "input_data": None, "backend_result": None}
schema_str = json.dumps(body.input_schema, indent=2)
user_message = f"Schema:\n```json\n{schema_str}\n```\n\nGenerate data for: {body.prompt}"
# Resolve body.model via the registry so non-Anthropic selections are
# routed through 9Router with the correct prefix (cx/, gc/).
# If body.model is unset or unknown, fall back to whichever aux model
# is available (prefers Claude, else any connected subscription).
from backend.apps.agents.providers.registry import (
_find_builtin_model,
resolve_model_id_for_sdk,
resolve_aux_model,
)
settings = load_settings()
if body.model and _find_builtin_model(body.model) is not None:
api_model = resolve_model_id_for_sdk(body.model, settings)
else:
try:
api_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
except ValueError as e:
return {"error": str(e), "input_data": None, "backend_result": None}
client = _get_anthropic_client(api_model)
try:
resp = await client.messages.create(
model=api_model,
max_tokens=4000,
system=AUTO_RUN_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_message}],
)
from backend.apps.agents.agent_manager import _safe_resp_text
raw = _safe_resp_text(resp).strip()
if not raw:
return {"error": "Aux model returned no content.", "input_data": None, "backend_result": None}
if raw.startswith("```"):
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
if raw.endswith("```"):
raw = raw[:-3]
input_data = json.loads(raw)
validation_err = _validate_against_schema(input_data, body.input_schema)
if validation_err:
return {"input_data": input_data, "backend_result": None, "error": validation_err}
# SECURITY: this endpoint used to accept arbitrary `backend_code` in
# the request body and pass it straight to execute_backend_code —
# which is an unsandboxed `python -c` subprocess. That gave anyone
# holding the install token (which is readable by every process
# running as the same OS user, and is also handed to every agent
# subprocess via OPENSWARM_AUTH_TOKEN) a one-shot RCE primitive.
# The field is now ignored at the model layer; backend code can
# only run via /api/outputs/execute against a persisted Output.
return {
"input_data": input_data,
"backend_result": None,
"stdout": None,
"stderr": None,
"error": None,
}
except json.JSONDecodeError:
return {"error": "Failed to parse generated data as JSON", "input_data": None, "backend_result": None}
except Exception as e:
logger.exception("Auto-run failed")
return {"error": str(e), "input_data": None, "backend_result": None}
@outputs.router.post("/execute")
async def execute_output(body: OutputExecute):
output = _load(body.output_id)
@@ -627,72 +541,3 @@ async def execute_output(body: OutputExecute):
).model_dump()
AUTO_RUN_AGENT_SYSTEM_PROMPT = """\
You are a data-gathering agent. Your job is to use the available tools to collect \
real data, then render it into a structured View.
You have access to MCP tools (e.g. Gmail, calendar, etc.) that let you fetch live data. \
Use them as needed to fulfil the user's request.
When you have gathered enough data, call the **RenderOutput** tool with:
- `output_id`: `{output_id}`
- `input_data`: a JSON object conforming to this schema:
```json
{schema}
```
Do NOT fabricate data. Use the tools to get real information, then structure it to match \
the schema above. If a tool call fails, report the error clearly.\
"""
@outputs.router.post("/auto-run-agent")
async def auto_run_agent(body: AutoRunAgentRequest):
"""Launch a temporary agent session that uses MCP tools to gather data for a view."""
from backend.apps.agents.agent_manager import agent_manager, FULL_TOOLS
from backend.apps.agents.models import AgentConfig
output = _load(body.output_id)
schema_str = json.dumps(body.input_schema or output.input_schema, indent=2)
system_prompt = AUTO_RUN_AGENT_SYSTEM_PROMPT.format(
output_id=body.output_id,
schema=schema_str,
)
allowed_tools = list(FULL_TOOLS)
for tool_name in body.forced_tools:
if tool_name not in allowed_tools:
allowed_tools.append(tool_name)
config = AgentConfig(
name=f"AutoRun: {output.name}",
model=body.model,
mode="agent",
system_prompt=system_prompt,
allowed_tools=allowed_tools,
max_turns=20,
)
session = await agent_manager.launch_agent(config)
await agent_manager.send_message(
session.id,
body.prompt,
context_paths=body.context_paths if body.context_paths else None,
forced_tools=body.forced_tools if body.forced_tools else None,
)
return {"session_id": session.id}
@outputs.router.delete("/auto-run-agent/{session_id}")
async def cleanup_auto_run_agent(session_id: str):
"""Delete a temporary auto-run agent session."""
from backend.apps.agents.agent_manager import agent_manager
try:
await agent_manager.delete_session(session_id)
except Exception as e:
logger.warning(f"Auto-run agent cleanup failed for {session_id}: {e}")
return {"ok": True}
-1
View File
@@ -33,7 +33,6 @@ BUILTIN_TOOLS: list[BuiltinTool] = [
BuiltinTool(name="CronCreate", description="Create a scheduled or recurring task", category="scheduling", deferred=True),
BuiltinTool(name="CronList", description="List all scheduled tasks", category="scheduling", deferred=True),
BuiltinTool(name="CronDelete", description="Delete a scheduled task", category="scheduling", deferred=True),
BuiltinTool(name="RenderOutput", description="Render a reusable View artifact with structured input data", category="views", deferred=True),
# Agent tools
BuiltinTool(name="Agent", display_name="CreateAgent", description="Spawn a sub-agent to handle a complex subtask", category="agents"),
BuiltinTool(name="InvokeAgent", description="Invoke a copy of an existing agent with a new message, preserving full conversation context", category="agents"),
+1 -111
View File
@@ -608,7 +608,7 @@ async def session_clear(session_id: str):
Preserves session.messages (so the chat UI keeps the visible history)
but clears the SDK-side conversation by minting a new sdk_session_id.
Also drops active_mcps/active_outputs so the user starts fresh.
Also drops active_mcps so the user starts fresh.
"""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.ws_manager import ws_manager as _ws
@@ -617,7 +617,6 @@ async def session_clear(session_id: str):
return JSONResponse({"error": "session not found"}, status_code=404)
session.sdk_session_id = None
session.active_mcps = []
session.active_outputs = []
session.compacted_through_msg_id = None
session.tokens = {"input": 0, "output": 0}
session.cost_usd = 0.0
@@ -634,115 +633,6 @@ async def session_clear(session_id: str):
return JSONResponse({"cleared": True})
@app.post("/api/outputs-meta/{action}")
async def outputs_meta(action: str, request: Request):
"""Back the openswarm-outputs-meta stdio MCP server.
Mirrors mcp_meta but for Outputs/Views: list/search/activate against
the canonical Output store. Anti-hallucination: unknown ids return
the valid options instead of activating. Schema spilled into context
on next turn via session.active_outputs (consumed by
_build_outputs_context in agent_manager).
"""
from datetime import datetime as _dt
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.outputs.outputs import _load_all as load_all_outputs, _save as save_output
body = await request.json()
parent_session_id = body.get("parent_session_id", "")
def _all_outputs_ranked() -> list[dict]:
items = []
for o in load_all_outputs():
items.append({
"id": o.id,
"name": o.name,
"description": (o.description or "").strip(),
"use_count": int(getattr(o, "use_count", 0) or 0),
"last_used_at": getattr(o, "last_used_at", None),
})
# Sort: most-used first, then most-recent, then alphabetical.
items.sort(key=lambda i: (-(i["use_count"] or 0), -(len(i["last_used_at"] or "")), i["name"].lower()))
return items
if action == "list":
outputs = _all_outputs_ranked()
session = agent_manager.sessions.get(parent_session_id) if parent_session_id else None
active_set = set(session.active_outputs) if session else set()
active = [{**o, "status": "active"} for o in outputs if o["id"] in active_set]
available = [{**o, "status": "available"} for o in outputs if o["id"] not in active_set]
return JSONResponse({"active": active, "available": available})
if action == "search":
query = (body.get("query") or "").strip().lower()
if not query:
return JSONResponse({"matches": []})
outputs = _all_outputs_ranked()
session = agent_manager.sessions.get(parent_session_id) if parent_session_id else None
active_set = set(session.active_outputs) if session else set()
scored: list[tuple[int, dict]] = []
for o in outputs:
hay = f"{o['name']} {o['description']}".lower()
score = 0
for tok in query.split():
if tok and tok in hay:
score += 2 if tok in o["name"].lower() else 1
# Use-count bonus so frequently-rendered Outputs surface for
# generic queries like "dashboard" / "chart".
if score:
score += min(3, (o["use_count"] or 0) // 5)
annotated = {**o, "status": "active" if o["id"] in active_set else "available"}
scored.append((score, annotated))
scored.sort(key=lambda t: (-t[0], 0 if t[1]["status"] == "active" else 1, t[1]["name"]))
matches = [s for _, s in scored[:5]]
return JSONResponse({"matches": matches})
if action == "activate":
output_id = (body.get("output_id") or "").strip()
reason = body.get("reason") or ""
if not output_id:
return JSONResponse({"error": "output_id is required"}, status_code=400)
if not parent_session_id:
return JSONResponse({"error": "parent_session_id is required"}, status_code=400)
session = agent_manager.sessions.get(parent_session_id)
if not session:
return JSONResponse({"error": "session not found"}, status_code=404)
all_outputs = load_all_outputs()
match = next((o for o in all_outputs if o.id == output_id), None)
if not match:
return JSONResponse({"status": "unknown_output", "available": [o.id for o in all_outputs]})
if output_id in session.active_outputs:
return JSONResponse({"status": "already_active", "output_id": output_id})
session.active_outputs.append(output_id)
session.needs_fork = True
# Bump usage stats. Activation is a pretty strong "model intends
# to use this" signal — even if RenderOutput isn't called, the
# Output is meaningfully in scope. last_used_at lets ranking
# surface "stuff I touched recently" to the model later.
try:
match.use_count = int(getattr(match, "use_count", 0) or 0) + 1
match.last_used_at = _dt.now().isoformat()
save_output(match)
except Exception:
logger.exception("Failed to bump Output usage stats")
try:
from backend.apps.agents.ws_manager import ws_manager as _ws
await _ws.send_to_session(parent_session_id, "agent:status", {
"session_id": parent_session_id,
"status": session.status,
"session": session.model_dump(mode="json"),
})
except Exception:
logger.exception("Failed to broadcast post-activate session status")
pass # Output activation captured via session dump on close
return JSONResponse({"status": "activated", "output_id": output_id})
return JSONResponse({"error": f"unknown action: {action}"}, status_code=400)
@app.post("/api/invoke-agent/run")
async def invoke_agent_run(request: Request):
"""Fork an existing agent session and send it a new message.
-2
View File
@@ -653,11 +653,9 @@ def test_active_mcps_persistence_on_session():
from backend.apps.agents.models import AgentSession
s = AgentSession(id="x", name="t", model="sonnet", mode="agent")
s.active_mcps = ["gmail", "slack"]
s.active_outputs = ["view-1"]
dumped = json.dumps(s.model_dump(mode="json"))
rehydrated = AgentSession.model_validate(json.loads(dumped))
assert rehydrated.active_mcps == ["gmail", "slack"]
assert rehydrated.active_outputs == ["view-1"]
# ===========================================================================
+2 -1
View File
@@ -12,7 +12,8 @@ React 18 + TypeScript + webpack 5 + Redux. Entry: `src/app/Main.tsx`. Dev server
- **Spatial dashboard** — agents are draggable nodes on a canvas; layout + selection state lives in Redux.
- **Settings draft persistence** — `AppSettings.dismissed_mcp_suggestions` is a map of MCP id → ISO timestamp; preserve this shape when modifying settings serialization.
- **Onboarding wizard** (`src/app/pages/Onboarding/`) — 8-step agentic cursor walkthrough. Cursor offsets, fit-to-view, AC popup timing, and group-meta dedup were each delicate to land; verify visually after touching this code.
- **Onboarding wizard** (`src/app/components/Onboarding/`) — 8-step agentic cursor walkthrough. Cursor offsets, fit-to-view, AC popup timing, and group-meta dedup were each delicate to land; verify visually after touching this code. Note: steps 3/5/6 launch real agent sessions that hit the cloud's analytics ingest — don't treat them as visual-only.
- **SignInGate** (`src/app/components/SignInGate.tsx`, mounted in `Main.tsx`) — first-launch gate that captures `user_id` + email via Google OAuth or email magic link, hitting the cloud's `/api/auth/{google,email}/*`. Auto-dismisses for users with a valid bearer.
- **Custom providers** — `AppSettings.custom_providers: CustomProvider[]` supports any OpenAI-compatible endpoint (e.g. LM Studio).
## Conventions
+2 -22
View File
@@ -16,7 +16,6 @@ import ViewQuiltOutlinedIcon from '@mui/icons-material/ViewQuiltOutlined';
import { useAppSelector, useAppDispatch } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { fetchSkills } from '@/shared/state/skillsSlice';
const GoogleIcon: React.FC<{ sx?: object }> = ({ sx }) => (
@@ -95,21 +94,18 @@ const CommandPicker: React.FC<Props> = ({ trigger, filter, onSelect, onClose, vi
const modesMap = useAppSelector((s) => s.modes.items);
const builtinTools = useAppSelector((s) => s.tools.builtinTools);
const customTools = useAppSelector((s) => s.tools.items);
const outputItems = useAppSelector((s) => s.outputs.items);
const [selectedIndex, setSelectedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const toolsLoaded = useAppSelector((s) => s.tools.loaded);
const builtinLoaded = useAppSelector((s) => s.tools.builtinLoaded);
const outputsLoaded = useAppSelector((s) => s.outputs.loaded);
const skillsLoaded = useAppSelector((s) => s.skills.loaded);
useEffect(() => {
if (!builtinLoaded) dispatch(fetchBuiltinTools());
if (!toolsLoaded) dispatch(fetchTools());
if (!outputsLoaded) dispatch(fetchOutputs());
if (!skillsLoaded) dispatch(fetchSkills());
}, [dispatch, builtinLoaded, toolsLoaded, outputsLoaded, skillsLoaded]);
}, [dispatch, builtinLoaded, toolsLoaded, skillsLoaded]);
const items: CommandPickerItem[] = useMemo(() => {
let all: CommandPickerItem[] = [];
@@ -252,22 +248,6 @@ const CommandPicker: React.FC<Props> = ({ trigger, filter, onSelect, onClose, vi
}
}
for (const out of Object.values(outputItems)) {
if (out.permission === 'deny') continue;
const cmd = out.name.toLowerCase().replace(/\s+/g, '-');
atItems.push({
id: `view-${out.id}`,
type: 'context' as const,
category: 'Apps',
name: out.name,
description: out.description || `Render ${out.name} view`,
command: cmd,
icon: <ViewQuiltOutlinedIcon sx={{ fontSize: 15 }} />,
toolNames: ['RenderOutput'],
iconKey: 'View',
});
}
all = atItems;
}
@@ -279,7 +259,7 @@ const CommandPicker: React.FC<Props> = ({ trigger, filter, onSelect, onClose, vi
item.command.toLowerCase().includes(lower) ||
item.description.toLowerCase().includes(lower),
);
}, [trigger, skills, modesMap, builtinTools, customTools, outputItems, filter]);
}, [trigger, skills, modesMap, builtinTools, customTools, filter]);
const flatItems = useMemo(() => {
const result: { item: CommandPickerItem; isGroupStart: boolean; category: string }[] = [];
@@ -71,7 +71,6 @@ export const S = {
// new — apps / views page
appsNewButton: 'apps-new-button',
appBuilderInput: 'app-builder-input',
appBuilderSubmit: 'app-builder-submit',
appCardLatest: 'app-card-latest',
+2 -21
View File
@@ -618,16 +618,6 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval';
const renderItems: RenderItem[] = useMemo(() => {
const isOutputCall = (m: AgentMessage) =>
m.role === 'tool_call' && typeof m.content === 'object' && m.content.tool === 'RenderOutput';
const isOutputResult = (m: AgentMessage) => {
if (m.role !== 'tool_result') return false;
try {
const parsed = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
return !!(parsed?.output_id && parsed?.frontend_code);
} catch { return false; }
};
const items: RenderItem[] = [];
let i = 0;
while (i < activeBranchMessages.length) {
@@ -643,15 +633,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
i++;
}
const regular: typeof activeBranchMessages = [];
const outputItems: typeof activeBranchMessages = [];
for (const m of group) {
if (isOutputCall(m) || isOutputResult(m)) { outputItems.push(m); continue; }
regular.push(m);
}
const calls = regular.filter((m) => m.role === 'tool_call');
const results = regular.filter((m) => m.role === 'tool_result');
const calls = group.filter((m) => m.role === 'tool_call');
const results = group.filter((m) => m.role === 'tool_result');
const pairs: ToolPair[] = calls.map((call, idx) => ({
type: 'tool_pair' as const,
id: `pair-${call.id}`,
@@ -699,8 +682,6 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
callCount: calls.length,
} satisfies ToolGroup);
}
items.push(...outputItems);
} else {
if (!msg.hidden) {
items.push(msg);
@@ -83,12 +83,6 @@ export default function ContextDrawer() {
))}
</Section>
<Section title="Active Outputs / Views" emptyText="None — model must OutputActivate before RenderOutput">
{((session as any).active_outputs || []).map((o: string) => (
<Pill key={o} label={o} color={c.text.primary} />
))}
</Section>
<Section title="Compaction" emptyText="">
<Typography variant="caption" sx={{ color: c.text.secondary }}>
{session.compacted_through_msg_id
@@ -23,7 +23,6 @@ import { openSettingsModal } from '@/shared/state/settingsSlice';
import { useAppDispatch } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { SKILL_COLOR } from '@/app/components/richEditorUtils';
import ViewBubble from './ViewBubble';
import PlanPicker from '@/app/components/PlanPicker';
import { ErrorSlime } from '@/app/components/ErrorSlime';
@@ -842,26 +841,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
);
}
if (role === 'tool_call') {
const toolData = typeof content === 'object' ? content : {};
const toolInput = toolData.input || {};
if (toolData.tool === 'RenderOutput') {
return <ViewBubble toolInput={toolInput} isStreaming={isStreaming} />;
}
return null;
}
if (role === 'tool_result') {
let parsedContent: any = null;
try { parsedContent = typeof content === 'string' ? JSON.parse(content) : content; } catch {}
if (parsedContent?.output_id && parsedContent?.frontend_code) {
return (
<ViewBubble
toolInput={{ output_id: parsedContent.output_id, input_data: parsedContent.input_data || {} }}
toolResult={parsedContent}
/>
);
}
if (role === 'tool_call' || role === 'tool_result') {
return null;
}
@@ -1,278 +0,0 @@
import React, { useState, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Collapse from '@mui/material/Collapse';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import Icon from '@mui/material/Icon';
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import CloseIcon from '@mui/icons-material/Close';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useAppSelector } from '@/shared/hooks';
import { SERVE_BASE } from '@/shared/state/outputsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ViewPreview from '../Views/ViewPreview';
interface Props {
toolInput: Record<string, any>;
toolResult?: string | Record<string, any>;
isStreaming?: boolean;
}
const ViewBubble: React.FC<Props> = ({ toolInput, toolResult, isStreaming }) => {
const c = useClaudeTokens();
const [expanded, setExpanded] = useState(false);
const [showInputs, setShowInputs] = useState(false);
const outputId = toolInput?.output_id;
const inputData = toolInput?.input_data || {};
const outputsMap = useAppSelector((state) => state.outputs.items);
const output = outputId ? outputsMap[outputId] : null;
const parsedResult = useMemo(() => {
if (!toolResult) return null;
if (typeof toolResult === 'object') return toolResult;
try { return JSON.parse(toolResult as string); } catch { return null; }
}, [toolResult]);
const frontendCode = parsedResult?.frontend_code || (output?.files?.['index.html'] ?? '') || '';
const backendResult = parsedResult?.backend_result || null;
const outputName = parsedResult?.output_name || output?.name || 'App';
const outputColor = c.accent.primary;
const outputIcon = output?.icon || 'view_quilt';
const hasPreview = !!frontendCode.trim();
const serveUrl = outputId ? `${SERVE_BASE}/${outputId}/serve/index.html` : undefined;
const inputEntries = Object.entries(inputData);
if (isStreaming && !hasPreview) {
return (
<Box sx={{ width: '100%', my: 1 }}>
<Box
sx={{
borderLeft: `3px solid ${outputColor}`,
borderRadius: '0 12px 12px 0',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderLeftColor: outputColor,
borderLeftWidth: 3,
borderLeftStyle: 'solid',
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
gap: 1.5,
}}
>
<Icon sx={{ fontSize: 20, color: outputColor }}>{outputIcon}</Icon>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 600, color: c.text.primary, flex: 1 }}>
{outputName}
</Typography>
<Box
sx={{
width: 16,
height: 16,
border: `2px solid ${outputColor}`,
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'output-spin 0.8s linear infinite',
'@keyframes output-spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
<Typography sx={{ fontSize: '0.75rem', color: c.text.tertiary }}>
Rendering
</Typography>
</Box>
</Box>
);
}
return (
<>
<Box sx={{ width: '100%', my: 1.5 }}>
<Box
sx={{
borderRadius: '12px',
overflow: 'hidden',
bgcolor: c.bg.surface,
boxShadow: c.shadow.md,
border: `1px solid ${c.border.medium}`,
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
bgcolor: outputColor,
borderRadius: '12px 0 0 12px',
zIndex: 1,
},
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2.5,
py: 1.25,
pl: 3,
background: `linear-gradient(135deg, ${outputColor}08 0%, transparent 60%)`,
borderBottom: `1px solid ${c.border.subtle}`,
}}
>
<Icon sx={{ fontSize: 22, color: outputColor }}>{outputIcon}</Icon>
<Typography
sx={{
fontSize: '0.95rem',
fontWeight: 700,
color: c.text.primary,
flex: 1,
letterSpacing: '-0.01em',
}}
>
{outputName}
</Typography>
{inputEntries.length > 0 && (
<IconButton
size="small"
onClick={() => setShowInputs(!showInputs)}
sx={{
color: c.text.tertiary,
p: 0.5,
transform: showInputs ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
}}
>
<ExpandMoreIcon sx={{ fontSize: 18 }} />
</IconButton>
)}
{hasPreview && (
<IconButton
size="small"
onClick={() => setExpanded(true)}
sx={{ color: c.text.tertiary, p: 0.5, '&:hover': { color: outputColor } }}
>
<OpenInFullIcon sx={{ fontSize: 16 }} />
</IconButton>
)}
</Box>
{/* Collapsible input params */}
<Collapse in={showInputs}>
<Box
sx={{
px: 2.5,
pl: 3,
py: 1,
bgcolor: c.bg.secondary,
borderBottom: `1px solid ${c.border.subtle}`,
}}
>
{inputEntries.map(([key, val]) => {
const display = typeof val === 'string' ? val : JSON.stringify(val);
return (
<Box key={key} sx={{ display: 'flex', gap: 1, py: 0.25 }}>
<Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, fontFamily: c.font.mono, minWidth: 80 }}>
{key}
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.secondary, fontFamily: c.font.mono, wordBreak: 'break-word' }}>
{display.length > 120 ? display.slice(0, 120) + '…' : display}
</Typography>
</Box>
);
})}
</Box>
</Collapse>
{/* Preview */}
{hasPreview && (
<Box
sx={{
height: 350,
position: 'relative',
overflow: 'hidden',
}}
>
<ViewPreview
serveUrl={serveUrl}
frontendCode={frontendCode}
inputData={inputData}
backendResult={backendResult}
/>
</Box>
)}
{parsedResult?.error && (
<Box sx={{ px: 2.5, pl: 3, py: 1, bgcolor: c.status.errorBg, borderTop: `1px solid ${c.border.subtle}` }}>
<Typography sx={{ fontSize: '0.75rem', color: c.status.error }}>
{parsedResult.error}
</Typography>
</Box>
)}
</Box>
</Box>
{/* Fullscreen dialog */}
<Dialog
open={expanded}
onClose={() => setExpanded(false)}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
height: '85vh',
display: 'flex',
flexDirection: 'column',
borderRadius: '12px',
overflow: 'hidden',
},
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2.5,
py: 1.25,
borderBottom: `1px solid ${c.border.subtle}`,
background: `linear-gradient(135deg, ${outputColor}08 0%, transparent 60%)`,
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
bgcolor: outputColor,
},
}}
>
<Icon sx={{ fontSize: 22, color: outputColor, ml: 1 }}>{outputIcon}</Icon>
<Typography sx={{ fontWeight: 700, flex: 1, fontSize: '1rem' }}>{outputName}</Typography>
<IconButton onClick={() => setExpanded(false)} size="small" sx={{ color: c.text.tertiary }}>
<CloseIcon />
</IconButton>
</Box>
<DialogContent sx={{ p: 0, flex: 1, overflow: 'hidden' }}>
<ViewPreview
serveUrl={serveUrl}
frontendCode={frontendCode}
inputData={inputData}
backendResult={backendResult}
/>
</DialogContent>
</Dialog>
</>
);
};
export default ViewBubble;
@@ -3,12 +3,10 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import RefreshIcon from '@mui/icons-material/Refresh';
import BoltIcon from '@mui/icons-material/Bolt';
import CloseIcon from '@mui/icons-material/Close';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import { Output, autoRunOutput, autoRunAgentOutput, executeOutput, OutputExecuteResult, getBackendCode, SERVE_BASE } from '@/shared/state/outputsSlice';
import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
import { setViewCardPosition, setViewCardSize, removeViewCard } from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -71,11 +69,8 @@ const DashboardViewCard: React.FC<Props> = ({
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const previewRef = useRef<ViewPreviewHandle>(null);
const [inputData, setInputData] = useState<Record<string, any>>(() => getDefault(output.input_schema));
const [backendResult, setBackendResult] = useState<Record<string, any> | null>(null);
const [autoRunning, setAutoRunning] = useState(false);
const hasAutoRun = !!(output.auto_run_config?.enabled && output.auto_run_config?.prompt);
const [inputData] = useState<Record<string, any>>(() => getDefault(output.input_schema));
const [backendResult] = useState<Record<string, any> | null>(null);
// ---- Drag via header ----
const DRAG_THRESHOLD = 3;
@@ -235,70 +230,6 @@ const DashboardViewCard: React.FC<Props> = ({
previewRef.current?.reload();
};
const handleAutoRun = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!output.auto_run_config?.prompt) return;
setAutoRunning(true);
const config = output.auto_run_config;
const forcedToolNames = config.forced_tools?.flatMap((ft) => ft.tools) ?? [];
try {
if (forcedToolNames.length > 0) {
const res = await dispatch(autoRunAgentOutput({
prompt: config.prompt,
input_schema: output.input_schema,
output_id: output.id,
model: config.model,
forced_tools: forcedToolNames,
context_paths: config.context_paths,
})).unwrap();
// For agent-based auto-run, we execute with default input for now
// since the agent session result flow is complex for dashboard cards
const execRes = await dispatch(executeOutput({
output_id: output.id,
input_data: inputData,
})).unwrap();
setInputData(execRes.input_data);
setBackendResult(execRes.backend_result);
} else {
const res = await dispatch(autoRunOutput({
prompt: config.prompt,
input_schema: output.input_schema,
context_paths: config.context_paths,
forced_tools: forcedToolNames.length > 0 ? forcedToolNames : undefined,
model: config.model,
})).unwrap();
if (res.input_data) {
setInputData(res.input_data);
// Auto-run no longer executes backend.py inline (the server-side
// endpoint dropped that field — it was a direct RCE primitive).
// Chain a separate executeOutput against the persisted Output so
// backend code still runs for dashboards that need backend_result.
if (getBackendCode(output)) {
try {
const execRes = await dispatch(executeOutput({
output_id: output.id,
input_data: res.input_data,
})).unwrap();
setBackendResult(execRes.backend_result);
} catch {
// Backend execution failure shouldn't break the input render.
setBackendResult(null);
}
} else {
setBackendResult(res.backend_result);
}
}
}
} catch {
// Silently handle errors on dashboard
} finally {
setAutoRunning(false);
}
};
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
@@ -437,22 +368,6 @@ const DashboardViewCard: React.FC<Props> = ({
</IconButton>
</Tooltip>
{hasAutoRun && (
<Tooltip title={autoRunning ? 'Running...' : 'Auto Run'} placement="top">
<span>
<IconButton
size="small"
onClick={handleAutoRun}
onPointerDown={(e) => e.stopPropagation()}
disabled={autoRunning}
sx={{ color: '#f59e0b', p: 0.5, '&:hover': { color: '#d97706' } }}
>
{autoRunning ? <CircularProgress size={14} sx={{ color: '#f59e0b' }} /> : <BoltIcon sx={{ fontSize: 16 }} />}
</IconButton>
</span>
</Tooltip>
)}
<Tooltip title="Remove from dashboard" placement="top">
<IconButton
size="small"
@@ -52,57 +52,6 @@ function getDefault(schema: SchemaNode): any {
}
}
const STRING_STUBS: Record<string, string> = {
name: 'Jane Smith', first_name: 'Jane', last_name: 'Smith', firstName: 'Jane', lastName: 'Smith',
email: 'jane@example.com', url: 'https://example.com', website: 'https://example.com',
phone: '+1 (555) 123-4567', address: '123 Main St, Springfield',
city: 'Springfield', state: 'CA', country: 'US', zip: '90210',
title: 'Sample Title', subject: 'Hello World', message: 'This is a sample message.',
description: 'A brief description of the item.', content: 'Lorem ipsum dolor sit amet.',
username: 'janesmith', password: 'P@ssw0rd!', token: 'tok_sample_abc123',
id: 'item_001', uuid: '550e8400-e29b-41d4-a716-446655440000',
date: '2025-03-15', time: '14:30', datetime: '2025-03-15T14:30:00Z',
color: '#4a90d9', label: 'Important', tag: 'sample', category: 'general',
query: 'search term', search: 'example query', text: 'Sample text content',
path: '/home/user/file.txt', file: 'document.pdf', filename: 'report.pdf',
company: 'Acme Corp', organization: 'Acme Corp',
};
function stubString(key: string): string {
const lower = key.toLowerCase().replace(/[-_]/g, '');
for (const [pattern, val] of Object.entries(STRING_STUBS)) {
if (lower === pattern.toLowerCase().replace(/[-_]/g, '') || lower.endsWith(pattern.toLowerCase().replace(/[-_]/g, ''))) {
return val;
}
}
return `sample_${key}`;
}
function getStubbed(schema: SchemaNode, key?: string): any {
if (schema.default !== undefined) return schema.default;
if (schema.enum && schema.enum.length > 0) return schema.enum[0];
switch (schema.type) {
case 'string': return stubString(key || 'value');
case 'number': return 42;
case 'integer': return 7;
case 'boolean': return true;
case 'array': {
if (!schema.items) return [];
return [getStubbed(schema.items, key ? `${key}_item` : 'item')];
}
case 'object': {
const obj: Record<string, any> = {};
if (schema.properties) {
for (const [k, v] of Object.entries(schema.properties)) {
obj[k] = getStubbed(v, k);
}
}
return obj;
}
default: return stubString(key || 'value');
}
}
const InputSchemaForm: React.FC<Props> = ({ schema, value, onChange, label, depth = 0 }) => {
const c = useClaudeTokens();
@@ -309,5 +258,5 @@ const InputSchemaForm: React.FC<Props> = ({ schema, value, onChange, label, dept
);
};
export { getDefault, getStubbed };
export { getDefault };
export default InputSchemaForm;
+5 -522
View File
@@ -7,7 +7,6 @@ import TextField from '@mui/material/TextField';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Tooltip from '@mui/material/Tooltip';
import Switch from '@mui/material/Switch';
import CircularProgress from '@mui/material/CircularProgress';
import SaveIcon from '@mui/icons-material/Save';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
@@ -20,23 +19,18 @@ import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import FolderIcon from '@mui/icons-material/Folder';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import BoltIcon from '@mui/icons-material/Bolt';
import Collapse from '@mui/material/Collapse';
import Chip from '@mui/material/Chip';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createDraftSession, removeDraftSession, fetchSession, AgentMessage } from '@/shared/state/agentsSlice';
import { createOutput, updateOutput, Output, executeOutput, OutputExecuteResult, autoRunOutput, autoRunAgentOutput, cleanupAutoRunAgent, AutoRunConfig, SERVE_BASE } from '@/shared/state/outputsSlice';
import { createSessionWs } from '@/shared/ws/WebSocketManager';
import { createDraftSession, removeDraftSession, fetchSession } from '@/shared/state/agentsSlice';
import { createOutput, updateOutput, Output, executeOutput, OutputExecuteResult, SERVE_BASE } from '@/shared/state/outputsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import AgentChat from '../AgentChat/AgentChat';
import ChatInput, { ChatInputHandle } from '../AgentChat/ChatInput';
import RefreshIcon from '@mui/icons-material/Refresh';
import ViewPreview, { ViewPreviewHandle } from './ViewPreview';
import InputSchemaForm, { getDefault, getStubbed } from './InputSchemaForm';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import { getDefault } from './InputSchemaForm';
import CodeEditor from './CodeEditor';
import { ElementSelectionProvider } from '@/app/components/ElementSelectionContext';
import { captureViewThumbnail } from './captureViewThumbnail';
@@ -107,160 +101,6 @@ function buildFileTree(filePaths: string[]): FileTreeNode[] {
return root;
}
interface LogEntryProps {
msg: AgentMessage;
c: ReturnType<typeof useClaudeTokens>;
}
const LogEntry: React.FC<LogEntryProps> = ({ msg, c }) => {
const [open, setOpen] = useState(false);
if (msg.role === 'user') return null;
if (msg.role === 'assistant') {
const text = typeof msg.content === 'string'
? msg.content
: Array.isArray(msg.content)
? msg.content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('')
: JSON.stringify(msg.content);
if (!text.trim()) return null;
return (
<Box sx={{ mb: 0.5 }}>
<Box
onClick={() => setOpen(!open)}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'pointer', '&:hover': { opacity: 0.8 } }}
>
<ExpandMoreIcon sx={{ fontSize: 14, color: c.text.ghost, transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: '0.15s' }} />
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{text.slice(0, 120)}{text.length > 120 ? '…' : ''}
</Typography>
</Box>
<Collapse in={open}>
<Typography sx={{ fontSize: '0.72rem', color: c.text.secondary, whiteSpace: 'pre-wrap', pl: 2.5, pt: 0.5, fontFamily: c.font.mono, lineHeight: 1.5 }}>
{text}
</Typography>
</Collapse>
</Box>
);
}
if (msg.role === 'tool_call') {
const tc = typeof msg.content === 'object' ? msg.content as Record<string, any> : {};
return (
<Box sx={{ mb: 0.5 }}>
<Box
onClick={() => setOpen(!open)}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'pointer', '&:hover': { opacity: 0.8 } }}
>
<ExpandMoreIcon sx={{ fontSize: 14, color: c.text.ghost, transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: '0.15s' }} />
<Chip
label={tc.tool || 'tool'}
size="small"
sx={{ height: 18, fontSize: '0.68rem', fontWeight: 600, fontFamily: c.font.mono, bgcolor: c.accent.primary + '20', color: c.accent.primary }}
/>
{tc.input && (
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, ml: 0.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{JSON.stringify(tc.input).slice(0, 80)}
</Typography>
)}
</Box>
<Collapse in={open}>
<Box sx={{ pl: 2.5, pt: 0.5 }}>
<Typography component="pre" sx={{ fontSize: '0.68rem', color: c.text.secondary, fontFamily: c.font.mono, whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
{JSON.stringify(tc.input, null, 2)}
</Typography>
</Box>
</Collapse>
</Box>
);
}
if (msg.role === 'tool_result') {
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
return (
<Box sx={{ mb: 0.5 }}>
<Box
onClick={() => setOpen(!open)}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'pointer', '&:hover': { opacity: 0.8 } }}
>
<ExpandMoreIcon sx={{ fontSize: 14, color: c.text.ghost, transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: '0.15s' }} />
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost }}>
result ({content.length > 60 ? `${content.length} chars` : content.slice(0, 60)})
</Typography>
</Box>
<Collapse in={open}>
<Typography component="pre" sx={{ fontSize: '0.68rem', color: c.text.secondary, fontFamily: c.font.mono, whiteSpace: 'pre-wrap', pl: 2.5, pt: 0.5, maxHeight: 200, overflow: 'auto' }}>
{content}
</Typography>
</Collapse>
</Box>
);
}
if (msg.role === 'system') {
const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
return (
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, fontStyle: 'italic', mb: 0.5 }}>
{text}
</Typography>
);
}
return null;
};
interface AutoRunLogProps {
messages: AgentMessage[];
status: string | null;
logEndRef: React.RefObject<HTMLDivElement>;
c: ReturnType<typeof useClaudeTokens>;
}
const AutoRunLog: React.FC<AutoRunLogProps> = ({ messages, status, logEndRef, c }) => {
const isRunning = status === 'running' || status === 'waiting_approval';
const isDone = status === 'completed' || status === 'stopped';
const isError = status === 'error';
return (
<Box sx={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
border: `1px solid ${c.border.subtle}`,
borderRadius: 1,
overflow: 'hidden',
}}>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 0.75,
bgcolor: c.bg.secondary,
borderBottom: `1px solid ${c.border.subtle}`,
flexShrink: 0,
}}>
{isRunning && <CircularProgress size={12} sx={{ color: '#f59e0b' }} />}
{isDone && <CheckCircleOutlineIcon sx={{ fontSize: 14, color: c.accent.primary }} />}
{isError && <ErrorOutlineIcon sx={{ fontSize: 14, color: '#ef4444' }} />}
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: c.text.muted }}>
{isRunning ? 'Agent running…' : isDone ? 'Agent completed' : isError ? 'Agent error' : 'Execution log'}
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, ml: 'auto' }}>
{messages.length} messages
</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'auto', px: 1.5, py: 1 }}>
{messages.map((msg) => (
<LogEntry key={msg.id} msg={msg} c={c} />
))}
<div ref={logEndRef} />
</Box>
</Box>
);
};
interface ConsoleEntry {
timestamp: number;
inputData: Record<string, any>;
@@ -495,8 +335,6 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
const TAB_PREVIEW = 0;
const TAB_CODE = 1;
const TAB_TEST_INPUT = 2;
const TAB_AUTO_RUN = 3;
const TAB_CONSOLE = 4;
const [activeTab, setActiveTab] = useState(TAB_PREVIEW);
@@ -523,25 +361,8 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
const [consoleEntry, setConsoleEntry] = useState<ConsoleEntry | null>(null);
const [hasNewConsoleOutput, setHasNewConsoleOutput] = useState(false);
const savedAutoRun = output?.auto_run_config;
const [autoRunEnabled, setAutoRunEnabled] = useState(savedAutoRun?.enabled ?? false);
const [autoRunMode, setAutoRunMode] = useState(savedAutoRun?.mode ?? 'agent');
const [autoRunModel, setAutoRunModel] = useState(savedAutoRun?.model ?? 'sonnet');
const [autoRunning, setAutoRunning] = useState(false);
const autoRunInputRef = useRef<ChatInputHandle>(null);
const autoRunInitialized = useRef(false);
const previewRef = useRef<ViewPreviewHandle>(null);
const [autoRunSessionId, setAutoRunSessionId] = useState<string | null>(null);
const autoRunWsRef = useRef<ReturnType<typeof createSessionWs> | null>(null);
const autoRunLogEndRef = useRef<HTMLDivElement>(null);
const autoRunSession = useAppSelector((state) =>
autoRunSessionId ? state.agents.sessions[autoRunSessionId] : null
);
const autoRunMessages = autoRunSession?.messages ?? [];
const autoRunSessionStatus = autoRunSession?.status ?? null;
const SIDEBAR_MIN = 280;
const SIDEBAR_MAX = 800;
const [sidebarWidth, setSidebarWidth] = useState(420);
@@ -798,41 +619,10 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
try { return JSON.parse(schemaText); } catch { return { type: 'object', properties: {} }; }
}, [schemaText]);
const testInputDefault = useMemo(() => getDefault(parsedSchema), [parsedSchema]);
const [testInput, setTestInput] = useState<Record<string, any>>(testInputDefault);
useEffect(() => {
setTestInput(getDefault(parsedSchema));
}, [schemaText]);
useEffect(() => {
if (autoRunInitialized.current || !savedAutoRun) return;
if (!autoRunEnabled) return;
autoRunInitialized.current = true;
const timer = setTimeout(() => {
autoRunInputRef.current?.setContent(
savedAutoRun.prompt || '',
savedAutoRun.context_paths?.map((cp) => ({ path: cp.path, type: (cp.type as 'file' | 'directory') || 'file' })),
savedAutoRun.forced_tools,
);
}, 100);
return () => clearTimeout(timer);
}, [savedAutoRun, autoRunEnabled]);
const testInput = useMemo<Record<string, any>>(() => getDefault(parsedSchema), [parsedSchema]);
const savedRef = useRef(!!output);
const getAutoRunConfig = (): AutoRunConfig => {
const config = autoRunInputRef.current?.getConfig();
return {
enabled: autoRunEnabled,
prompt: config?.prompt ?? '',
context_paths: config?.contextPaths?.map((cp) => ({ path: cp.path, type: cp.type })) ?? [],
forced_tools: (config?.forcedTools ?? []).map(({ label, tools, iconKey }) => ({ label, tools, iconKey })),
mode: autoRunMode,
model: autoRunModel,
};
};
const buildBody = () => {
let schema: Record<string, any>;
try { schema = JSON.parse(schemaText); } catch { schema = { type: 'object', properties: {} }; }
@@ -848,7 +638,6 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
icon: 'view_quilt',
input_schema: schema,
files: outputFiles,
auto_run_config: getAutoRunConfig(),
};
};
@@ -944,137 +733,6 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
}
};
const handleAutoRun = async () => {
const config = autoRunInputRef.current?.getConfig();
if (!config?.prompt?.trim()) return;
setAutoRunning(true);
let schema: Record<string, any>;
try { schema = JSON.parse(schemaText); } catch { schema = { type: 'object', properties: {} }; }
const forcedToolNames = config.forcedTools.flatMap((ft) => ft.tools);
const eid = output?.id ?? createdIdRef.current;
if (forcedToolNames.length > 0 && eid) {
try {
const res = await dispatch(autoRunAgentOutput({
prompt: config.prompt,
input_schema: schema,
output_id: eid,
model: autoRunModel,
forced_tools: forcedToolNames,
context_paths: config.contextPaths.map((cp) => ({ path: cp.path, type: cp.type })),
})).unwrap();
setAutoRunSessionId(res.session_id);
const ws = createSessionWs(res.session_id);
ws.connect();
autoRunWsRef.current = ws;
} catch {
setAutoRunning(false);
}
} else {
try {
const backendCode = files['backend.py'] ?? null;
const res = await dispatch(autoRunOutput({
prompt: config.prompt,
input_schema: schema,
backend_code: backendCode || undefined,
context_paths: config.contextPaths.map((cp) => ({ path: cp.path, type: cp.type })),
forced_tools: forcedToolNames.length > 0 ? forcedToolNames : undefined,
model: autoRunModel,
})).unwrap();
if (res.input_data) {
setTestInput(res.input_data);
setExecuteResult({
output_id: output?.id ?? createdIdRef.current ?? '',
output_name: name,
frontend_code: files['index.html'] ?? '',
input_data: res.input_data,
backend_result: res.backend_result,
stdout: res.stdout ?? null,
stderr: res.stderr ?? null,
error: res.error,
});
setConsoleEntry({ timestamp: Date.now(), inputData: res.input_data, stdout: res.stdout ?? null, stderr: res.stderr ?? null, backendResult: res.backend_result, error: res.error, source: 'auto-run' });
setHasNewConsoleOutput(true);
setActiveTab(TAB_PREVIEW);
}
} catch {}
setAutoRunning(false);
}
};
useEffect(() => {
if (!autoRunSessionId || !autoRunSessionStatus) return;
if (autoRunSessionStatus !== 'completed' && autoRunSessionStatus !== 'error' && autoRunSessionStatus !== 'stopped') return;
let extracted = false;
for (const msg of autoRunMessages) {
if (msg.role !== 'tool_call' || typeof msg.content !== 'object') continue;
const tc = msg.content as { tool?: string; input?: Record<string, any> };
if (tc.tool !== 'RenderOutput' || !tc.input?.input_data) continue;
setTestInput(tc.input.input_data);
setExecuteResult({
output_id: output?.id ?? createdIdRef.current ?? '',
output_name: name,
frontend_code: files['index.html'] ?? '',
input_data: tc.input.input_data,
backend_result: null,
stdout: null,
stderr: null,
error: null,
});
setConsoleEntry({ timestamp: Date.now(), inputData: tc.input.input_data, stdout: null, stderr: null, backendResult: null, error: null, source: 'agent' });
setHasNewConsoleOutput(true);
setActiveTab(TAB_PREVIEW);
extracted = true;
break;
}
if (!extracted && autoRunSessionStatus === 'error') {
const lastSys = [...autoRunMessages].reverse().find((m) => m.role === 'system');
if (lastSys) {
const errMsg = typeof lastSys.content === 'string' ? lastSys.content : JSON.stringify(lastSys.content);
setExecuteResult({
output_id: output?.id ?? createdIdRef.current ?? '',
output_name: name,
frontend_code: files['index.html'] ?? '',
input_data: {},
backend_result: null,
stdout: null,
stderr: null,
error: errMsg,
});
setConsoleEntry({ timestamp: Date.now(), inputData: {}, stdout: null, stderr: null, backendResult: null, error: errMsg, source: 'agent' });
setHasNewConsoleOutput(true);
}
}
setAutoRunning(false);
if (autoRunWsRef.current) {
autoRunWsRef.current.disconnect();
autoRunWsRef.current = null;
}
cleanupAutoRunAgent(autoRunSessionId).catch(() => {});
setTimeout(() => setAutoRunSessionId(null), 300);
}, [autoRunSessionId, autoRunSessionStatus]);
useEffect(() => {
autoRunLogEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [autoRunMessages.length]);
useEffect(() => {
return () => {
if (autoRunWsRef.current) {
autoRunWsRef.current.disconnect();
autoRunWsRef.current = null;
}
if (autoRunSessionId) {
cleanupAutoRunAgent(autoRunSessionId).catch(() => {});
}
};
}, [autoRunSessionId]);
const workspaceServeUrl = workspaceId
? `${SERVE_BASE}/workspace/${workspaceId}/serve/index.html`
: undefined;
@@ -1164,7 +822,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
return () => {
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
};
}, [files, name, description, autoRunEnabled, autoRunMode, autoRunModel]);
}, [files, name, description]);
useEffect(() => {
return () => {
@@ -1280,26 +938,6 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
}}
/>
{autoRunEnabled && (
<Button
variant="outlined"
startIcon={autoRunning ? <CircularProgress size={14} /> : <BoltIcon sx={{ fontSize: 16 }} />}
onClick={handleAutoRun}
disabled={autoRunning}
size="small"
sx={{
borderColor: '#f59e0b40',
color: '#f59e0b',
textTransform: 'none',
fontWeight: 500,
fontSize: '0.8rem',
px: 1.5,
'&:hover': { borderColor: '#f59e0b', bgcolor: '#f59e0b10' },
}}
>
{autoRunning ? 'Running…' : 'Auto Run'}
</Button>
)}
{saveStatus === 'unsaved' && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost, fontStyle: 'italic', whiteSpace: 'nowrap' }}>
Unsaved changes
@@ -1371,8 +1009,6 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
>
<Tab label="Preview" value={TAB_PREVIEW} />
<Tab label="Code" value={TAB_CODE} />
<Tab label="Test Input" value={TAB_TEST_INPUT} />
<Tab label="Auto Run" value={TAB_AUTO_RUN} />
{showConsole && <Tab label="Console" value={TAB_CONSOLE} />}
</Tabs>
{activeTab === TAB_PREVIEW && (
@@ -1566,162 +1202,9 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
</Box>
</Box>
)}
{activeTab === TAB_TEST_INPUT && (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
borderBottom: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.secondary,
flexShrink: 0,
}}
>
<Button
size="small"
startIcon={<AutoFixHighIcon sx={{ fontSize: 15 }} />}
onClick={() => setTestInput(getStubbed(parsedSchema))}
sx={{
textTransform: 'none',
fontSize: '0.78rem',
color: c.accent.primary,
fontWeight: 500,
'&:hover': { bgcolor: c.bg.elevated },
}}
>
Fill sample data
</Button>
<Button
size="small"
onClick={() => setTestInput(getDefault(parsedSchema))}
sx={{
textTransform: 'none',
fontSize: '0.78rem',
color: c.text.muted,
fontWeight: 400,
'&:hover': { bgcolor: c.bg.elevated },
}}
>
Reset
</Button>
</Box>
<Box sx={{ p: 2, overflow: 'auto', flex: 1 }}>
<InputSchemaForm
schema={parsedSchema}
value={testInput}
onChange={setTestInput}
/>
</Box>
</Box>
)}
{activeTab === TAB_CONSOLE && (
<ConsolePanel entry={consoleEntry} c={c} />
)}
<Box sx={{ display: activeTab === TAB_AUTO_RUN ? 'flex' : 'none', flexDirection: 'column', height: '100%' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 1,
borderBottom: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.secondary,
flexShrink: 0,
}}
>
<Switch
checked={autoRunEnabled}
onChange={(_, v) => setAutoRunEnabled(v)}
size="small"
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: '#f59e0b' },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#f59e0b' },
}}
/>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 500, color: autoRunEnabled ? c.text.primary : c.text.muted }}>
{autoRunEnabled ? 'Auto Run enabled' : 'Auto Run disabled'}
</Typography>
<Box sx={{ flex: 1 }} />
{autoRunEnabled && (
<Button
size="small"
startIcon={autoRunning ? <CircularProgress size={12} /> : <BoltIcon sx={{ fontSize: 14 }} />}
onClick={handleAutoRun}
disabled={autoRunning}
sx={{
textTransform: 'none',
fontSize: '0.78rem',
color: '#f59e0b',
fontWeight: 500,
'&:hover': { bgcolor: '#f59e0b10' },
}}
>
{autoRunning ? 'Running…' : 'Run Now'}
</Button>
)}
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{autoRunEnabled ? (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', p: 2, gap: 1.5, overflow: 'hidden' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem', lineHeight: 1.6, flexShrink: 0 }}>
Describe what data to generate for this app. When triggered, an LLM will produce input data matching your schema and populate the preview.
</Typography>
<Box data-onboarding="app-builder-input" sx={{ width: '100%' }}>
<ChatInput
ref={autoRunInputRef}
autoRunMode
onSend={() => {}}
mode={autoRunMode}
onModeChange={setAutoRunMode}
model={autoRunModel}
onModelChange={setAutoRunModel}
/>
</Box>
<Button
variant="contained"
startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon sx={{ fontSize: 16 }} />}
onClick={() => handleSave(false)}
disabled={saving || !name.trim()}
size="small"
sx={{
alignSelf: 'flex-start',
flexShrink: 0,
bgcolor: c.accent.primary,
textTransform: 'none',
fontWeight: 500,
fontSize: '0.8rem',
px: 2,
'&:hover': { bgcolor: c.accent.hover },
}}
>
{saving ? 'Saving…' : 'Save'}
</Button>
{(autoRunSessionId || autoRunMessages.length > 0) && (
<AutoRunLog
messages={autoRunMessages}
status={autoRunSessionStatus}
logEndRef={autoRunLogEndRef}
c={c}
/>
)}
</Box>
) : (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5 }}>
<BoltIcon sx={{ fontSize: 40, color: c.text.ghost, opacity: 0.3 }} />
<Typography sx={{ color: c.text.ghost, fontSize: '0.88rem' }}>
Enable Auto Run to generate live data for this app
</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.78rem', maxWidth: 360, textAlign: 'center', lineHeight: 1.5 }}>
Configure a prompt that describes what data to generate. An LLM will produce input matching your schema and populate the preview automatically.
</Typography>
</Box>
)}
</Box>
</Box>
</Box>
</Box>
</Box>
-1
View File
@@ -103,7 +103,6 @@ export interface AgentSession {
context_overflow?: { reason: string; message: string; at: string } | null;
mcp_suggestions?: Array<{ id: string; title: string; description: string; reason?: string }>;
mcp_suggestions_is_vague?: boolean;
active_outputs?: string[];
compacted_through_msg_id?: string | null;
// Transient frontend-only WS connection state. Independent of
// `status` (which describes the agent run itself). When the WS
-61
View File
@@ -6,15 +6,6 @@ const OUTPUTS_API = `${API_BASE}/outputs`;
export const SERVE_BASE = `${API_BASE}/outputs`;
export interface AutoRunConfig {
enabled: boolean;
prompt: string;
context_paths: Array<{ path: string; type: string }>;
forced_tools: Array<{ label: string; tools: string[]; iconKey?: string }>;
mode: string;
model: string;
}
export interface Output {
id: string;
name: string;
@@ -23,7 +14,6 @@ export interface Output {
input_schema: Record<string, any>;
files: Record<string, string>;
permission: string;
auto_run_config?: AutoRunConfig | null;
thumbnail?: string | null;
// Linkage so reopening App Builder reattaches to the in-progress session
// and reuses the on-disk workspace folder instead of seeding a fresh one.
@@ -135,57 +125,6 @@ export const executeOutput = createAsyncThunk(
}
);
export interface AutoRunResult {
input_data: Record<string, any> | null;
backend_result: Record<string, any> | null;
stdout: string | null;
stderr: string | null;
error: string | null;
}
export const autoRunOutput = createAsyncThunk(
'outputs/autoRun',
// backend_code intentionally NOT in the request shape. The server endpoint
// ignores it now (it was an unsandboxed-RCE primitive); callers that want
// backend execution should chain executeOutput against a persisted Output.
async (body: { prompt: string; input_schema: Record<string, any>; context_paths?: Array<{ path: string; type: string }>; forced_tools?: string[]; model?: string }) => {
const res = await fetch(`${OUTPUTS_API}/auto-run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return (await res.json()) as AutoRunResult;
}
);
export interface AutoRunAgentResult {
session_id: string;
}
export const autoRunAgentOutput = createAsyncThunk(
'outputs/autoRunAgent',
async (body: {
prompt: string;
input_schema: Record<string, any>;
output_id: string;
model?: string;
forced_tools?: string[];
context_paths?: Array<{ path: string; type: string }>;
}) => {
const res = await fetch(`${OUTPUTS_API}/auto-run-agent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Auto-run agent launch failed: ${res.status}`);
return (await res.json()) as AutoRunAgentResult;
}
);
export async function cleanupAutoRunAgent(sessionId: string): Promise<void> {
await fetch(`${OUTPUTS_API}/auto-run-agent/${sessionId}`, { method: 'DELETE' });
}
const outputsSlice = createSlice({
name: 'outputs',
initialState,