mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 14:28:59 +02:00
[eric] app builder cleanup — remove Auto Run, Test Input, RenderOutput tool + Outputs activation gate
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user