feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
246
workbench_store.py
Normal file
246
workbench_store.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from card_store import (
|
||||
NANOBOT_WORKSPACE,
|
||||
json_script_text,
|
||||
normalize_card_id,
|
||||
persist_card,
|
||||
template_html_path,
|
||||
)
|
||||
from session_store import normalize_session_chat_id
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
NANOBOT_REPO_DIR = BASE_DIR.parent / "nanobot"
|
||||
NANOBOT_RUNTIME_WORKSPACE = Path(
|
||||
os.getenv(
|
||||
"NANOBOT_RUNTIME_WORKSPACE",
|
||||
str(
|
||||
(NANOBOT_WORKSPACE / "workspace")
|
||||
if (NANOBOT_WORKSPACE / "workspace").exists()
|
||||
else NANOBOT_WORKSPACE
|
||||
),
|
||||
)
|
||||
).expanduser()
|
||||
WORKBENCH_DIR = NANOBOT_RUNTIME_WORKSPACE / "workbench"
|
||||
|
||||
|
||||
def ensure_workbench_store_dirs() -> None:
|
||||
WORKBENCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
_script_modules: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _load_script_module(module_name: str, script_path: Path):
|
||||
cached = _script_modules.get(module_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if not script_path.exists():
|
||||
raise RuntimeError(f"missing helper script: {script_path}")
|
||||
spec = importlib.util.spec_from_file_location(module_name, script_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"failed to load helper script: {script_path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules.setdefault(module_name, module)
|
||||
spec.loader.exec_module(module)
|
||||
_script_modules[module_name] = module
|
||||
return module
|
||||
|
||||
|
||||
def _workbench_board_module():
|
||||
ensure_workbench_store_dirs()
|
||||
module = _load_script_module(
|
||||
"nanobot_web_workbench_board",
|
||||
NANOBOT_REPO_DIR / "scripts" / "workbench_board.py",
|
||||
)
|
||||
module.ensure_workbench(WORKBENCH_DIR)
|
||||
return module
|
||||
|
||||
|
||||
def _coerce_workbench_record(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||
item_id = normalize_card_id(str(raw.get("id", "")))
|
||||
chat_id = normalize_session_chat_id(str(raw.get("chat_id", "web") or "web"))
|
||||
if not item_id or not chat_id:
|
||||
return None
|
||||
|
||||
kind = str(raw.get("kind", "text") or "text").strip().lower()
|
||||
if kind not in {"text", "question"}:
|
||||
kind = "text"
|
||||
|
||||
raw_choices = raw.get("choices", [])
|
||||
choices = [str(choice) for choice in raw_choices] if isinstance(raw_choices, list) else []
|
||||
raw_template_state = raw.get("template_state", {})
|
||||
template_state = raw_template_state if isinstance(raw_template_state, dict) else {}
|
||||
|
||||
return {
|
||||
"id": item_id,
|
||||
"chat_id": chat_id,
|
||||
"kind": kind,
|
||||
"title": str(raw.get("title", "")),
|
||||
"content": str(raw.get("content", "")),
|
||||
"question": str(raw.get("question", "")),
|
||||
"choices": choices,
|
||||
"response_value": str(raw.get("response_value", "")),
|
||||
"slot": str(raw.get("slot", "")),
|
||||
"template_key": str(raw.get("template_key", "")),
|
||||
"template_state": template_state,
|
||||
"context_summary": str(raw.get("context_summary", "")),
|
||||
"promotable": bool(raw.get("promotable", True)),
|
||||
"source_card_id": str(raw.get("source_card_id", "")),
|
||||
"created_at": str(raw.get("created_at", "")),
|
||||
"updated_at": str(raw.get("updated_at", "")),
|
||||
}
|
||||
|
||||
|
||||
def _materialize_workbench_content(item: dict[str, Any]) -> str:
|
||||
if item.get("kind") != "text":
|
||||
return str(item.get("content", ""))
|
||||
|
||||
template_key = str(item.get("template_key", "")).strip()
|
||||
if not template_key:
|
||||
return str(item.get("content", ""))
|
||||
|
||||
html_path = template_html_path(template_key)
|
||||
try:
|
||||
template_html = html_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return (
|
||||
'<div style="padding:16px;border:1px solid #fecaca;border-radius:12px;'
|
||||
'background:#fef2f2;color:#991b1b;font:600 14px/1.4 system-ui,sans-serif;">'
|
||||
f"Missing template: {html.escape(template_key)}"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
state_payload = item.get("template_state", {})
|
||||
if not isinstance(state_payload, dict):
|
||||
state_payload = {}
|
||||
|
||||
item_id = html.escape(str(item.get("id", "")))
|
||||
safe_template_key = html.escape(template_key)
|
||||
return (
|
||||
f'<div data-nanobot-card-root data-card-id="{item_id}" data-template-key="{safe_template_key}">'
|
||||
f'<script type="application/json" data-card-state>{json_script_text(state_payload)}</script>'
|
||||
f"{template_html}"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def _workbench_notification_target(raw: dict[str, Any]) -> bool:
|
||||
for key in ("surface", "target", "placement"):
|
||||
value = str(raw.get(key, "") or "").strip().lower()
|
||||
if value == "workbench":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _persist_workbench_item(
|
||||
raw: dict[str, Any], *, default_chat_id: str | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
normalized_chat_id = normalize_session_chat_id(
|
||||
str(raw.get("chat_id", default_chat_id or "web") or default_chat_id or "web")
|
||||
)
|
||||
if not normalized_chat_id:
|
||||
return None
|
||||
|
||||
template_state = raw.get("template_state", {})
|
||||
if not isinstance(template_state, dict):
|
||||
template_state = {}
|
||||
|
||||
workbench_board = _workbench_board_module()
|
||||
item = workbench_board.upsert_item(
|
||||
WORKBENCH_DIR,
|
||||
chat_id=normalized_chat_id,
|
||||
item_id=str(raw.get("id", "") or ""),
|
||||
kind=str(raw.get("kind", "text") or "text"),
|
||||
title=str(raw.get("title", "")),
|
||||
content=str(raw.get("content", "")),
|
||||
question=str(raw.get("question", "")),
|
||||
choices=[str(choice) for choice in raw.get("choices", [])]
|
||||
if isinstance(raw.get("choices"), list)
|
||||
else [],
|
||||
response_value=str(raw.get("response_value", "")),
|
||||
slot=str(raw.get("slot", "")),
|
||||
template_key=str(raw.get("template_key", "")),
|
||||
template_state=template_state,
|
||||
context_summary=str(raw.get("context_summary", "")),
|
||||
promotable=bool(raw.get("promotable", True)),
|
||||
source_card_id=str(raw.get("source_card_id", "")),
|
||||
)
|
||||
normalized = _coerce_workbench_record(item)
|
||||
if normalized is None:
|
||||
return None
|
||||
normalized["content"] = _materialize_workbench_content(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def _load_workbench_items(chat_id: str) -> list[dict[str, Any]]:
|
||||
workbench_board = _workbench_board_module()
|
||||
items = workbench_board.collect_items(WORKBENCH_DIR, chat_id)
|
||||
rendered: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
normalized = _coerce_workbench_record(item)
|
||||
if normalized is None:
|
||||
continue
|
||||
normalized["content"] = _materialize_workbench_content(normalized)
|
||||
rendered.append(normalized)
|
||||
rendered.sort(key=lambda item: str(item.get("updated_at", "")), reverse=True)
|
||||
return rendered
|
||||
|
||||
|
||||
def _delete_workbench_item(chat_id: str, item_id: str) -> bool:
|
||||
workbench_board = _workbench_board_module()
|
||||
return bool(workbench_board.delete_item(WORKBENCH_DIR, chat_id, item_id))
|
||||
|
||||
|
||||
def _promote_workbench_item(chat_id: str, item_id: str) -> dict[str, Any]:
|
||||
workbench_board = _workbench_board_module()
|
||||
item = workbench_board.load_item(WORKBENCH_DIR, chat_id, item_id)
|
||||
card_payload = {
|
||||
"id": f"workbench-{item['id']}",
|
||||
"kind": item.get("kind", "text"),
|
||||
"title": item.get("title", ""),
|
||||
"content": item.get("content", ""),
|
||||
"question": item.get("question", ""),
|
||||
"choices": item.get("choices", []),
|
||||
"response_value": item.get("response_value", ""),
|
||||
"slot": f"workbench:{chat_id}:{item['id']}",
|
||||
"lane": "context",
|
||||
"priority": 78,
|
||||
"state": "active",
|
||||
"template_key": item.get("template_key", ""),
|
||||
"template_state": item.get("template_state", {}),
|
||||
"context_summary": item.get("context_summary", ""),
|
||||
"chat_id": chat_id,
|
||||
}
|
||||
persisted = persist_card(card_payload)
|
||||
if persisted is None:
|
||||
raise RuntimeError("failed to promote workbench item to card")
|
||||
workbench_board.delete_item(WORKBENCH_DIR, chat_id, item_id)
|
||||
return persisted
|
||||
|
||||
|
||||
coerce_workbench_record = _coerce_workbench_record
|
||||
persist_workbench_item = _persist_workbench_item
|
||||
load_workbench_items = _load_workbench_items
|
||||
delete_workbench_item = _delete_workbench_item
|
||||
promote_workbench_item = _promote_workbench_item
|
||||
workbench_notification_target = _workbench_notification_target
|
||||
|
||||
__all__ = [
|
||||
"WORKBENCH_DIR",
|
||||
"coerce_workbench_record",
|
||||
"delete_workbench_item",
|
||||
"ensure_workbench_store_dirs",
|
||||
"load_workbench_items",
|
||||
"persist_workbench_item",
|
||||
"promote_workbench_item",
|
||||
"workbench_notification_target",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue