Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,4 @@ agent_file_system/TASK_HISTORY.md
!build_template.py
docs/LIVING_UI_DEVELOPER_GUIDE.md
agent_file_system/ACTIONS.md
agent_bundle/
3,613 changes: 3,613 additions & 0 deletions app/data/playbooks/catalogue.json

Large diffs are not rendered by default.

202 changes: 201 additions & 1 deletion app/ui_layer/adapters/browser_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import os
import re
import shutil
import sys
import time
import uuid
from datetime import datetime
Expand All @@ -17,7 +18,7 @@
from aiohttp.client_exceptions import ClientConnectionResetError

from agent_core.utils.logger import logger
from app.config import AGENT_WORKSPACE_ROOT
from app.config import AGENT_WORKSPACE_ROOT, APP_DATA_PATH
from app.ui_layer.adapters.base import InterfaceAdapter
from app.ui_layer.settings import (
# General settings
Expand Down Expand Up @@ -1145,6 +1146,17 @@ async def _on_start(self) -> None:
"/api/living-ui/import", self._living_ui_import_handler
)

# Agent profile bundle import/export routes
self._app.router.add_get(
"/api/profile/export", self._profile_export_handler
)
self._app.router.add_post(
"/api/profile/inspect", self._profile_inspect_handler
)
self._app.router.add_post(
"/api/profile/import", self._profile_import_handler
)

# Integration bridge routes (Living UI → external APIs)
from app.living_ui.integration_bridge import IntegrationBridge

Expand Down Expand Up @@ -1822,6 +1834,10 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None:
name = data.get("name", "External App")
asyncio.create_task(self._handle_living_ui_import(source, name))

# Playbook catalogue handlers
elif msg_type == "playbook_list":
await self._handle_playbook_list()

# WhatsApp QR code flow handlers
elif msg_type == "whatsapp_start_qr":
await self._handle_whatsapp_start_qr()
Expand Down Expand Up @@ -2920,6 +2936,130 @@ async def _living_ui_import_handler(self, request: "web.Request") -> "web.Respon
logger.error(f"[LIVING_UI] Upload staging error: {e}")
return web.json_response({"error": str(e)}, status=500)

# ─────────────────────────────────────────────────────────────────────
# Agent profile bundle (.craftbot) — export / inspect / import
# ─────────────────────────────────────────────────────────────────────

async def _profile_export_handler(self, request: "web.Request") -> "web.Response":
"""Build a .craftbot bundle of the current agent and return it."""
from aiohttp import web
from app.ui_layer.settings.profile_bundle import export_profile
import shutil

description = request.query.get("description", "")
try:
result = export_profile(description=description)
except Exception as exc:
logger.error(f"[PROFILE_BUNDLE] Export failed: {exc}", exc_info=True)
return web.json_response({"error": str(exc)}, status=500)

if not result.get("success"):
return web.json_response(
{"error": result.get("error", "Export failed")}, status=500
)

bundle_path = Path(result["path"])
filename = result["filename"]
try:
payload = bundle_path.read_bytes()
finally:
# Clean up the temp file + its parent dir immediately. Bundles are
# small enough (no node_modules) to hold in memory briefly.
shutil.rmtree(bundle_path.parent, ignore_errors=True)

return web.Response(
body=payload,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Type": "application/octet-stream",
"Content-Length": str(len(payload)),
},
)

async def _stage_uploaded_bundle(self, request: "web.Request") -> Optional[str]:
"""Read the multipart upload and save the bundle to a temp file."""
import tempfile

reader = await request.multipart()
bundle_path: Optional[str] = None
async for part in reader:
if part.name == "file":
tmp = tempfile.NamedTemporaryFile(
suffix=".craftbot",
prefix="craftbot_profile_in_",
delete=False,
)
while True:
chunk = await part.read_chunk()
if not chunk:
break
tmp.write(chunk)
tmp.close()
bundle_path = tmp.name
return bundle_path

async def _profile_inspect_handler(self, request: "web.Request") -> "web.Response":
"""Read a bundle's manifest so the frontend can render a preview modal."""
from aiohttp import web
from app.ui_layer.settings.profile_bundle import inspect_bundle

try:
bundle_path = await self._stage_uploaded_bundle(request)
if not bundle_path:
return web.json_response(
{"error": "No bundle file uploaded"}, status=400
)
result = inspect_bundle(bundle_path)
# Return the temp path so the subsequent /api/profile/import call
# can reuse it instead of re-uploading the bundle.
result["bundle_path"] = bundle_path
return web.json_response(result)
except Exception as exc:
logger.error(f"[PROFILE_BUNDLE] Inspect failed: {exc}", exc_info=True)
return web.json_response({"error": str(exc)}, status=500)

async def _profile_import_handler(self, request: "web.Request") -> "web.Response":
"""Apply a previously-inspected bundle to the agent."""
from aiohttp import web
from app.ui_layer.settings.profile_bundle import import_profile

try:
payload = await request.json()
except Exception:
return web.json_response(
{"error": "Invalid JSON body"}, status=400
)

bundle_path = payload.get("bundle_path") or ""
mode = payload.get("mode", "merge")
if not bundle_path:
return web.json_response(
{"error": "bundle_path is required"}, status=400
)

try:
# Pass the live LivingUIManager so imported projects land in its
# in-memory state. Without this, the manager's stale state will
# overwrite our file on the next status update / watchdog tick.
result = import_profile(
bundle_path,
mode=mode,
living_ui_manager=self._living_ui_manager,
)
except Exception as exc:
logger.error(f"[PROFILE_BUNDLE] Import failed: {exc}", exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
finally:
# Best-effort cleanup of the staged upload.
try:
p = Path(bundle_path)
if p.exists():
p.unlink()
except Exception:
pass

return web.json_response(result)

async def _handle_living_ui_state_update(self, data: Dict[str, Any]) -> None:
"""Handle state update from a Living UI for agent awareness."""
try:
Expand Down Expand Up @@ -6245,6 +6385,66 @@ async def _handle_living_ui_project_setting_update(
{"type": "living_ui_project_setting_update", "data": result}
)

# =====================
# Playbook Handlers
# =====================

async def _handle_playbook_list(self) -> None:
"""Read the bundled playbook catalogue and broadcast it to the client.

Lookup order mirrors `get_default_picture_path` for read-only bundled
assets: APP_DATA_PATH first (source mode + writable per-user dir),
then `_MEIPASS/app/data/playbooks` so packaged builds resolve too.
"""
candidates = [APP_DATA_PATH / "playbooks" / "catalogue.json"]
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
candidates.append(
Path(meipass) / "app" / "data" / "playbooks" / "catalogue.json"
)

catalogue_path: Optional[Path] = next(
(p for p in candidates if p.exists()), None
)

if catalogue_path is None:
await self._broadcast(
{
"type": "playbook_list",
"data": {
"success": False,
"error": "Playbook catalogue not found.",
"playbooks": [],
},
}
)
return

try:
with open(catalogue_path, "r", encoding="utf-8") as f:
catalogue = json.load(f)
await self._broadcast(
{
"type": "playbook_list",
"data": {
"success": True,
"playbooks": catalogue.get("playbooks", []),
},
}
)
except Exception as e:
logger.error(f"[PLAYBOOK] Failed to read catalogue: {e}")
await self._broadcast(
{
"type": "playbook_list",
"data": {
"success": False,
"error": str(e),
"playbooks": [],
},
}
)

# =====================
# Marketplace Handlers
# =====================
Expand Down
23 changes: 23 additions & 0 deletions app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { Button, IconButton, SlashCommandAutocomplete, StatusIndicator, Attachme
import type { SlashCommandAutocompleteHandle } from '../ui'
import { useDerivedAgentStatus } from '../../hooks'
import { ChatMessageItem } from '../../pages/Chat/ChatMessage'
import { useAppDispatch, useAppSelector } from '../../store/hooks'
import { selectPendingPrefill } from '../../store/selectors/chatInput'
import { clearPendingPrefill } from '../../store/slices/chatInputSlice'
import styles from './Chat.module.css'

// Pending attachment type
Expand Down Expand Up @@ -123,6 +126,8 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) {
}, [messages])

const [input, setInput] = useState('')
const dispatch = useAppDispatch()
const pendingPrefill = useAppSelector(selectPendingPrefill)
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([])
const [attachmentError, setAttachmentError] = useState<string | null>(null)
const [isDragOver, setIsDragOver] = useState(false)
Expand Down Expand Up @@ -274,6 +279,24 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])

// Consume a one-shot prefill payload from the chatInput slice (e.g. when the
// user picks a playbook). Replaces the current input so the prompt is ready
// to send or edit, then clears the payload so it doesn't re-apply.
useEffect(() => {
if (pendingPrefill === null) return
setInput(pendingPrefill)
dispatch(clearPendingPrefill())
// Focus + move caret to the end after the textarea has updated.
setTimeout(() => {
const ta = inputRef.current
if (ta) {
ta.focus()
const end = ta.value.length
ta.setSelectionRange(end, end)
}
}, 0)
}, [pendingPrefill, dispatch])

const handleChatReply = useCallback((
sessionId: string | undefined,
displayName: string,
Expand Down
14 changes: 11 additions & 3 deletions app/ui_layer/browser/frontend/src/components/layout/TopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'
import { Sun, Moon, Github } from 'lucide-react'
import { IconButton } from '../ui'
import React, { useState } from 'react'
import { Sun, Moon, Github, BookOpen } from 'lucide-react'
import { IconButton, PlaybookModal } from '../ui'
import { useTheme } from '../../contexts/ThemeContext'
import { useWebSocket } from '../../contexts/WebSocketContext'
import { StatusIndicator } from '../ui/StatusIndicator'
Expand All @@ -23,6 +23,7 @@ export function TopBar() {
const { theme, toggleTheme } = useTheme()
const { connected, actions, messages } = useWebSocket()
const version = useAppSelector(selectVersion)
const [playbookOpen, setPlaybookOpen] = useState(false)

// Derive agent status from actions and messages
const derivedStatus = useDerivedAgentStatus({
Expand Down Expand Up @@ -55,6 +56,12 @@ export function TopBar() {

<div className={styles.right}>
{version && <span className={styles.versionBadge}>v{version}</span>}
<IconButton
icon={<BookOpen />}
onClick={() => setPlaybookOpen(true)}
size="sm"
tooltip="Playbooks"
/>
<IconButton
icon={theme === 'dark' ? <Sun /> : <Moon />}
onClick={toggleTheme}
Expand All @@ -74,6 +81,7 @@ export function TopBar() {
onClick={() => window.open('https://discord.gg/bSdZf9HSgq', '_blank')}
/>
</div>
<PlaybookModal isOpen={playbookOpen} onClose={() => setPlaybookOpen(false)} />
</header>
)
}
Loading