Merge pull request #50 from patherly/feat/dual-mode-tree-editor
feat: dual-mode tree editor with Code Mode and variables
This commit was merged in pull request #50.
This commit is contained in:
37
backend/alembic/versions/028_add_session_variables.py
Normal file
37
backend/alembic/versions/028_add_session_variables.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""add session_variables JSONB column to sessions
|
||||
|
||||
Revision ID: 028
|
||||
Revises: 027
|
||||
Create Date: 2026-02-09
|
||||
|
||||
Adds session_variables JSONB column for storing variable values
|
||||
collected during tree navigation (e.g., hostname, ticket_number).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '028'
|
||||
down_revision: Union[str, None] = '027'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'sessions',
|
||||
sa.Column(
|
||||
'session_variables',
|
||||
postgresql.JSONB(),
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
nullable=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('sessions', 'session_variables')
|
||||
@@ -295,6 +295,12 @@ async def export_session(
|
||||
content = generate_text_export(session, export_options)
|
||||
media_type = "text/plain"
|
||||
|
||||
# Resolve variables in export output
|
||||
session_vars = getattr(session, 'session_variables', None) or {}
|
||||
if session_vars:
|
||||
from app.services.variable_service import resolve_variables
|
||||
content = resolve_variables(content, session_vars)
|
||||
|
||||
# Mark as exported
|
||||
session.exported = True
|
||||
await db.commit()
|
||||
|
||||
132
backend/app/api/endpoints/tree_markdown.py
Normal file
132
backend/app/api/endpoints/tree_markdown.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""API endpoints for tree markdown import/export."""
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.api.deps import get_current_active_user, require_engineer_or_admin
|
||||
from app.core.permissions import can_access_tree, can_edit_tree
|
||||
from app.schemas.tree_markdown import (
|
||||
TreeMarkdownExportResponse,
|
||||
TreeMarkdownImportRequest,
|
||||
TreeMarkdownValidationResponse,
|
||||
MarkdownValidationError,
|
||||
)
|
||||
from app.services.tree_markdown_service import serialize_tree_to_markdown
|
||||
from app.services.tree_markdown_parser import parse_markdown_to_tree
|
||||
from app.services.tree_markdown_validator import validate_tree_markdown
|
||||
|
||||
router = APIRouter(prefix="/trees", tags=["tree-markdown"])
|
||||
|
||||
|
||||
@router.get("/{tree_id}/export-markdown", response_model=TreeMarkdownExportResponse)
|
||||
async def export_tree_markdown(
|
||||
tree_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Export a tree's JSONB structure as ResolutionFlow markdown."""
|
||||
result = await db.execute(
|
||||
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
|
||||
|
||||
if not can_access_tree(current_user, tree):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
metadata = {
|
||||
"name": tree.name or "",
|
||||
"description": tree.description or "",
|
||||
"category": tree.category or "",
|
||||
}
|
||||
markdown = serialize_tree_to_markdown(tree.tree_structure, metadata=metadata)
|
||||
return TreeMarkdownExportResponse(markdown=markdown)
|
||||
|
||||
|
||||
@router.put("/{tree_id}/import-markdown", response_model=TreeMarkdownValidationResponse)
|
||||
async def import_tree_markdown(
|
||||
tree_id: UUID,
|
||||
body: TreeMarkdownImportRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
):
|
||||
"""Parse markdown and update a tree's JSONB structure."""
|
||||
result = await db.execute(
|
||||
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
|
||||
)
|
||||
tree = result.scalar_one_or_none()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
|
||||
|
||||
if not can_edit_tree(current_user, tree):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
parse_result = parse_markdown_to_tree(body.markdown)
|
||||
|
||||
has_errors = any(e.severity == "error" for e in parse_result.errors)
|
||||
if has_errors:
|
||||
return TreeMarkdownValidationResponse(
|
||||
valid=False,
|
||||
errors=[
|
||||
MarkdownValidationError(
|
||||
line=e.line, column=e.column, message=e.message, severity=e.severity
|
||||
)
|
||||
for e in parse_result.errors
|
||||
],
|
||||
tree_structure=None,
|
||||
)
|
||||
|
||||
# Apply the parsed tree structure
|
||||
tree.tree_structure = parse_result.tree_structure
|
||||
await db.commit()
|
||||
|
||||
return TreeMarkdownValidationResponse(
|
||||
valid=True,
|
||||
errors=[
|
||||
MarkdownValidationError(
|
||||
line=e.line, column=e.column, message=e.message, severity=e.severity
|
||||
)
|
||||
for e in parse_result.errors
|
||||
],
|
||||
tree_structure=parse_result.tree_structure,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/validate-markdown", response_model=TreeMarkdownValidationResponse)
|
||||
async def validate_markdown(
|
||||
body: TreeMarkdownImportRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Validate markdown without saving. Returns errors and preview JSONB."""
|
||||
parse_result = parse_markdown_to_tree(body.markdown)
|
||||
all_errors = validate_tree_markdown(body.markdown)
|
||||
|
||||
# Deduplicate errors by message
|
||||
seen: set[str] = set()
|
||||
unique_errors: list[MarkdownValidationError] = []
|
||||
for e in all_errors:
|
||||
key = f"{e.line}:{e.column}:{e.message}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_errors.append(
|
||||
MarkdownValidationError(
|
||||
line=e.line, column=e.column, message=e.message, severity=e.severity
|
||||
)
|
||||
)
|
||||
|
||||
has_hard_errors = any(e.severity == "error" for e in unique_errors)
|
||||
|
||||
return TreeMarkdownValidationResponse(
|
||||
valid=not has_hard_errors,
|
||||
errors=unique_errors,
|
||||
tree_structure=parse_result.tree_structure,
|
||||
metadata=parse_result.metadata,
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared
|
||||
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
|
||||
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -24,3 +24,4 @@ api_router.include_router(accounts.router)
|
||||
api_router.include_router(webhooks.router)
|
||||
api_router.include_router(shares.router)
|
||||
api_router.include_router(shared.router) # Public endpoints (no auth)
|
||||
api_router.include_router(tree_markdown.router)
|
||||
|
||||
@@ -48,6 +48,9 @@ class Session(Base):
|
||||
ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
exported: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
session_variables: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")
|
||||
)
|
||||
scratchpad: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, server_default=sa.text("''")
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ class SessionUpdate(BaseModel):
|
||||
ticket_number: Optional[str] = Field(None, max_length=100)
|
||||
client_name: Optional[str] = Field(None, max_length=255)
|
||||
scratchpad: Optional[str] = None
|
||||
session_variables: Optional[dict[str, str]] = None
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
@@ -60,6 +61,7 @@ class SessionResponse(BaseModel):
|
||||
client_name: Optional[str] = None
|
||||
exported: bool
|
||||
scratchpad: str = ""
|
||||
session_variables: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
@validator('scratchpad', pre=True, always=True)
|
||||
def normalize_scratchpad(cls, v):
|
||||
|
||||
28
backend/app/schemas/tree_markdown.py
Normal file
28
backend/app/schemas/tree_markdown.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Pydantic schemas for tree markdown import/export."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TreeMarkdownExportResponse(BaseModel):
|
||||
"""Response for markdown export endpoint."""
|
||||
markdown: str
|
||||
|
||||
|
||||
class TreeMarkdownImportRequest(BaseModel):
|
||||
"""Request body for markdown import endpoint."""
|
||||
markdown: str
|
||||
|
||||
|
||||
class MarkdownValidationError(BaseModel):
|
||||
"""A single validation error with location info."""
|
||||
line: int
|
||||
column: int
|
||||
message: str
|
||||
severity: str # 'error' or 'warning'
|
||||
|
||||
|
||||
class TreeMarkdownValidationResponse(BaseModel):
|
||||
"""Response for markdown validation endpoint."""
|
||||
valid: bool
|
||||
errors: list[MarkdownValidationError]
|
||||
tree_structure: dict | None = None
|
||||
metadata: dict | None = None
|
||||
491
backend/app/services/tree_markdown_parser.py
Normal file
491
backend/app/services/tree_markdown_parser.py
Normal file
@@ -0,0 +1,491 @@
|
||||
"""
|
||||
Markdown → JSONB parser for ResolutionFlow tree structures.
|
||||
|
||||
Parses ResolutionFlow Markdown format (frontmatter-delimited node blocks)
|
||||
back into the recursive tree_structure JSONB dict.
|
||||
"""
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseError:
|
||||
"""A validation/parse error with location info."""
|
||||
line: int
|
||||
column: int
|
||||
message: str
|
||||
severity: str = "error" # 'error' or 'warning'
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseResult:
|
||||
"""Result of parsing markdown into a tree structure."""
|
||||
tree_structure: dict[str, Any] | None
|
||||
errors: list[ParseError] = field(default_factory=list)
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# Regex patterns
|
||||
FRONTMATTER_RE = re.compile(r"^---\s*$", re.MULTILINE)
|
||||
OPTION_RE = re.compile(
|
||||
r"^-\s*\[([A-Za-z0-9]+)\]\s*(.+?)(?:\s*→\s*@(\S+))?\s*$"
|
||||
)
|
||||
NEXT_NODE_RE = re.compile(r"^→\s*@(\S+)\s*$")
|
||||
EXPECTED_RE = re.compile(r"^\*\*Expected:\*\*\s*(.+)$")
|
||||
HEADING1_RE = re.compile(r"^#\s+(.+)$")
|
||||
HEADING2_RE = re.compile(r"^##\s+(.+)$")
|
||||
BLOCKQUOTE_RE = re.compile(r"^>\s*(.*)$")
|
||||
ORDERED_LIST_RE = re.compile(r"^\d+\.\s+(.+)$")
|
||||
COMMAND_BLOCK_START = re.compile(r"^```commands\s*$")
|
||||
COMMAND_BLOCK_END = re.compile(r"^```\s*$")
|
||||
|
||||
|
||||
def parse_markdown_to_tree(markdown: str) -> ParseResult:
|
||||
"""Parse ResolutionFlow markdown into a tree structure JSONB dict.
|
||||
|
||||
Args:
|
||||
markdown: The markdown string to parse.
|
||||
|
||||
Returns:
|
||||
ParseResult with tree_structure, errors, and optional metadata.
|
||||
"""
|
||||
errors: list[ParseError] = []
|
||||
raw_blocks = _split_into_blocks(markdown)
|
||||
|
||||
if not raw_blocks:
|
||||
errors.append(ParseError(line=1, column=1, message="No node blocks found"))
|
||||
return ParseResult(tree_structure=None, errors=errors)
|
||||
|
||||
# Check if the first block is a metadata block (has 'name' but no 'id'/'type')
|
||||
metadata = None
|
||||
node_blocks = raw_blocks
|
||||
first_block_text, _ = raw_blocks[0]
|
||||
meta = _try_parse_metadata_block(first_block_text)
|
||||
if meta is not None:
|
||||
metadata = meta
|
||||
node_blocks = raw_blocks[1:]
|
||||
|
||||
if not node_blocks:
|
||||
errors.append(ParseError(line=1, column=1, message="No node blocks found (only metadata)"))
|
||||
return ParseResult(tree_structure=None, errors=errors, metadata=metadata)
|
||||
|
||||
# Parse each block into a flat node dict
|
||||
flat_nodes: list[dict[str, Any]] = []
|
||||
for block_text, start_line in node_blocks:
|
||||
node, block_errors = _parse_block(block_text, start_line)
|
||||
errors.extend(block_errors)
|
||||
if node:
|
||||
flat_nodes.append(node)
|
||||
|
||||
if not flat_nodes:
|
||||
errors.append(ParseError(line=1, column=1, message="No valid nodes parsed"))
|
||||
return ParseResult(tree_structure=None, errors=errors)
|
||||
|
||||
# Check for duplicate IDs
|
||||
seen_ids: dict[str, int] = {}
|
||||
for node in flat_nodes:
|
||||
nid = node.get("id", "")
|
||||
if nid in seen_ids:
|
||||
errors.append(ParseError(
|
||||
line=node.get("_start_line", 1),
|
||||
column=1,
|
||||
message=f"Duplicate node ID: '{nid}'"
|
||||
))
|
||||
else:
|
||||
seen_ids[nid] = node.get("_start_line", 1)
|
||||
|
||||
# Reconstruct recursive tree from flat nodes
|
||||
tree, reconstruct_errors = _reconstruct_tree(flat_nodes)
|
||||
errors.extend(reconstruct_errors)
|
||||
|
||||
return ParseResult(tree_structure=tree, errors=errors, metadata=metadata)
|
||||
|
||||
|
||||
def _try_parse_metadata_block(block_text: str) -> dict[str, Any] | None:
|
||||
"""Try to parse a block as tree metadata (name, description, category, tags).
|
||||
|
||||
Returns metadata dict if the block contains 'name' but no 'id'/'type'.
|
||||
Returns None if it's a regular node block.
|
||||
"""
|
||||
lines = block_text.split("\n")
|
||||
fm_start = None
|
||||
fm_end = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "---":
|
||||
if fm_start is None:
|
||||
fm_start = i
|
||||
else:
|
||||
fm_end = i
|
||||
break
|
||||
|
||||
if fm_start is None or fm_end is None:
|
||||
return None
|
||||
|
||||
fm_data: dict[str, str] = {}
|
||||
for i in range(fm_start + 1, fm_end):
|
||||
line = lines[i].strip()
|
||||
if not line:
|
||||
continue
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
fm_data[key.strip()] = value.strip()
|
||||
|
||||
# It's a metadata block if it has 'name' but no 'id' and no 'type'
|
||||
if "name" in fm_data and "id" not in fm_data and "type" not in fm_data:
|
||||
metadata: dict[str, Any] = {"name": fm_data["name"]}
|
||||
if "description" in fm_data:
|
||||
metadata["description"] = fm_data["description"]
|
||||
if "category" in fm_data:
|
||||
metadata["category"] = fm_data["category"]
|
||||
if "tags" in fm_data:
|
||||
tags_str = fm_data["tags"].strip("[]")
|
||||
metadata["tags"] = [t.strip() for t in tags_str.split(",") if t.strip()]
|
||||
return metadata
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _split_into_blocks(markdown: str) -> list[tuple[str, int]]:
|
||||
"""Split markdown into blocks delimited by --- frontmatter markers.
|
||||
|
||||
Returns list of (block_text, start_line_number) tuples.
|
||||
"""
|
||||
lines = markdown.split("\n")
|
||||
blocks: list[tuple[str, int]] = []
|
||||
|
||||
# Find frontmatter boundaries (--- on its own line)
|
||||
fm_lines: list[int] = []
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "---":
|
||||
fm_lines.append(i)
|
||||
|
||||
# Pair up frontmatter markers: each block starts at a `---` and the
|
||||
# frontmatter ends at the next `---`. The body follows until the
|
||||
# next block's first `---` (or end of file).
|
||||
i = 0
|
||||
while i < len(fm_lines) - 1:
|
||||
start = fm_lines[i]
|
||||
end_fm = fm_lines[i + 1]
|
||||
|
||||
# Find the next block start (or EOF)
|
||||
next_block_start = len(lines)
|
||||
if i + 2 < len(fm_lines):
|
||||
next_block_start = fm_lines[i + 2]
|
||||
|
||||
block_lines = lines[start:next_block_start]
|
||||
block_text = "\n".join(block_lines)
|
||||
blocks.append((block_text, start + 1)) # 1-indexed line number
|
||||
|
||||
i += 2 # Jump to next frontmatter pair
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _parse_block(block_text: str, start_line: int) -> tuple[dict[str, Any] | None, list[ParseError]]:
|
||||
"""Parse a single frontmatter+body block into a node dict."""
|
||||
errors: list[ParseError] = []
|
||||
lines = block_text.split("\n")
|
||||
|
||||
# Extract frontmatter (between first and second ---)
|
||||
fm_start = None
|
||||
fm_end = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "---":
|
||||
if fm_start is None:
|
||||
fm_start = i
|
||||
else:
|
||||
fm_end = i
|
||||
break
|
||||
|
||||
if fm_start is None or fm_end is None:
|
||||
errors.append(ParseError(
|
||||
line=start_line, column=1,
|
||||
message="Block missing valid frontmatter delimiters"
|
||||
))
|
||||
return None, errors
|
||||
|
||||
# Parse YAML-like frontmatter (simple key: value)
|
||||
fm_data: dict[str, str] = {}
|
||||
for i in range(fm_start + 1, fm_end):
|
||||
line = lines[i].strip()
|
||||
if not line:
|
||||
continue
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
fm_data[key.strip()] = value.strip()
|
||||
|
||||
node_id = fm_data.get("id", "")
|
||||
node_type = fm_data.get("type", "")
|
||||
parent_id = fm_data.get("parent")
|
||||
|
||||
if not node_id:
|
||||
errors.append(ParseError(
|
||||
line=start_line, column=1,
|
||||
message="Node block missing 'id' in frontmatter"
|
||||
))
|
||||
return None, errors
|
||||
|
||||
if node_type not in ("decision", "action", "solution"):
|
||||
errors.append(ParseError(
|
||||
line=start_line, column=1,
|
||||
message=f"Invalid node type: '{node_type}' (must be decision, action, or solution)"
|
||||
))
|
||||
return None, errors
|
||||
|
||||
# Parse body (everything after frontmatter)
|
||||
body_lines = lines[fm_end + 1:]
|
||||
body_text = "\n".join(body_lines)
|
||||
|
||||
node: dict[str, Any] = {
|
||||
"id": node_id,
|
||||
"type": node_type,
|
||||
"_parent_id": parent_id,
|
||||
"_start_line": start_line,
|
||||
}
|
||||
|
||||
if node_type == "decision":
|
||||
_parse_decision_body(body_lines, node, start_line + fm_end + 1, errors)
|
||||
elif node_type == "action":
|
||||
_parse_action_body(body_lines, node, start_line + fm_end + 1, errors)
|
||||
elif node_type == "solution":
|
||||
_parse_solution_body(body_lines, node, start_line + fm_end + 1, errors)
|
||||
|
||||
return node, errors
|
||||
|
||||
|
||||
def _parse_decision_body(
|
||||
lines: list[str],
|
||||
node: dict[str, Any],
|
||||
body_start_line: int,
|
||||
errors: list[ParseError],
|
||||
) -> None:
|
||||
"""Parse the body of a decision node."""
|
||||
question = ""
|
||||
help_text_lines: list[str] = []
|
||||
options: list[dict[str, Any]] = []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
# Check for heading (question)
|
||||
m = HEADING1_RE.match(stripped)
|
||||
if m:
|
||||
question = m.group(1).strip()
|
||||
continue
|
||||
|
||||
# Check for blockquote (help_text)
|
||||
m = BLOCKQUOTE_RE.match(stripped)
|
||||
if m:
|
||||
help_text_lines.append(m.group(1))
|
||||
continue
|
||||
|
||||
# Check for option
|
||||
m = OPTION_RE.match(stripped)
|
||||
if m:
|
||||
opt_label = m.group(2).strip()
|
||||
opt_next = m.group(3) or ""
|
||||
options.append({
|
||||
"id": f"opt_{node['id']}_{len(options)}",
|
||||
"label": opt_label,
|
||||
"next_node_id": opt_next,
|
||||
})
|
||||
continue
|
||||
|
||||
node["question"] = question
|
||||
node["help_text"] = "\n".join(help_text_lines) if help_text_lines else ""
|
||||
node["options"] = options
|
||||
node["children"] = []
|
||||
|
||||
|
||||
def _parse_action_body(
|
||||
lines: list[str],
|
||||
node: dict[str, Any],
|
||||
body_start_line: int,
|
||||
errors: list[ParseError],
|
||||
) -> None:
|
||||
"""Parse the body of an action node."""
|
||||
title = ""
|
||||
description_lines: list[str] = []
|
||||
commands: list[str] = []
|
||||
expected_outcome = ""
|
||||
next_node_id = ""
|
||||
in_command_block = False
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
|
||||
# Command block handling
|
||||
if in_command_block:
|
||||
if COMMAND_BLOCK_END.match(stripped):
|
||||
in_command_block = False
|
||||
else:
|
||||
commands.append(line.rstrip())
|
||||
continue
|
||||
|
||||
if COMMAND_BLOCK_START.match(stripped):
|
||||
in_command_block = True
|
||||
continue
|
||||
|
||||
if not stripped:
|
||||
# Blank lines are part of description
|
||||
if title and not expected_outcome and not next_node_id:
|
||||
description_lines.append("")
|
||||
continue
|
||||
|
||||
# Title
|
||||
m = HEADING2_RE.match(stripped)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
continue
|
||||
|
||||
# Expected outcome
|
||||
m = EXPECTED_RE.match(stripped)
|
||||
if m:
|
||||
expected_outcome = m.group(1).strip()
|
||||
continue
|
||||
|
||||
# Next node reference
|
||||
m = NEXT_NODE_RE.match(stripped)
|
||||
if m:
|
||||
next_node_id = m.group(1).strip()
|
||||
continue
|
||||
|
||||
# Everything else is description
|
||||
description_lines.append(stripped)
|
||||
|
||||
# Trim leading and trailing empty lines from description
|
||||
while description_lines and not description_lines[-1].strip():
|
||||
description_lines.pop()
|
||||
while description_lines and not description_lines[0].strip():
|
||||
description_lines.pop(0)
|
||||
|
||||
node["title"] = title
|
||||
node["description"] = "\n".join(description_lines)
|
||||
node["commands"] = commands if commands else []
|
||||
node["expected_outcome"] = expected_outcome
|
||||
node["next_node_id"] = next_node_id
|
||||
node["children"] = []
|
||||
|
||||
|
||||
def _parse_solution_body(
|
||||
lines: list[str],
|
||||
node: dict[str, Any],
|
||||
body_start_line: int,
|
||||
errors: list[ParseError],
|
||||
) -> None:
|
||||
"""Parse the body of a solution node."""
|
||||
title = ""
|
||||
description_lines: list[str] = []
|
||||
resolution_steps: list[str] = []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
if title:
|
||||
description_lines.append("")
|
||||
continue
|
||||
|
||||
# Title
|
||||
m = HEADING2_RE.match(stripped)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
continue
|
||||
|
||||
# Ordered list item (resolution step)
|
||||
m = ORDERED_LIST_RE.match(stripped)
|
||||
if m:
|
||||
resolution_steps.append(m.group(1).strip())
|
||||
continue
|
||||
|
||||
# Everything else is description
|
||||
description_lines.append(stripped)
|
||||
|
||||
# Trim leading and trailing empty lines
|
||||
while description_lines and not description_lines[-1].strip():
|
||||
description_lines.pop()
|
||||
while description_lines and not description_lines[0].strip():
|
||||
description_lines.pop(0)
|
||||
|
||||
node["title"] = title
|
||||
node["description"] = "\n".join(description_lines)
|
||||
node["resolution_steps"] = resolution_steps
|
||||
node["solution"] = title # solution field required for publishing
|
||||
|
||||
|
||||
def _reconstruct_tree(flat_nodes: list[dict[str, Any]]) -> tuple[dict[str, Any] | None, list[ParseError]]:
|
||||
"""Reconstruct a recursive tree from flat nodes using parent references.
|
||||
|
||||
Returns (tree_structure, errors).
|
||||
"""
|
||||
errors: list[ParseError] = []
|
||||
|
||||
if not flat_nodes:
|
||||
return None, errors
|
||||
|
||||
# Build lookup
|
||||
node_map: dict[str, dict[str, Any]] = {}
|
||||
for node in flat_nodes:
|
||||
nid = node["id"]
|
||||
# Clean node (remove internal fields)
|
||||
clean = {k: v for k, v in node.items() if not k.startswith("_")}
|
||||
if "children" not in clean:
|
||||
clean["children"] = []
|
||||
node_map[nid] = clean
|
||||
|
||||
# Find root (node with no parent)
|
||||
root_id = None
|
||||
for node in flat_nodes:
|
||||
if node.get("_parent_id") is None:
|
||||
if root_id is not None:
|
||||
errors.append(ParseError(
|
||||
line=node.get("_start_line", 1),
|
||||
column=1,
|
||||
message=f"Multiple root nodes found: '{root_id}' and '{node['id']}'",
|
||||
))
|
||||
root_id = node["id"]
|
||||
|
||||
if root_id is None:
|
||||
# Fall back to first node
|
||||
root_id = flat_nodes[0]["id"]
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message="No root node found (no node without a parent). Using first node as root.",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
# Build children relationships
|
||||
for node in flat_nodes:
|
||||
parent_id = node.get("_parent_id")
|
||||
if parent_id and parent_id in node_map:
|
||||
child = node_map[node["id"]]
|
||||
node_map[parent_id]["children"].append(child)
|
||||
elif parent_id and parent_id not in node_map:
|
||||
errors.append(ParseError(
|
||||
line=node.get("_start_line", 1),
|
||||
column=1,
|
||||
message=f"Node '{node['id']}' references non-existent parent '{parent_id}'"
|
||||
))
|
||||
|
||||
# Validate option references
|
||||
for nid, node in node_map.items():
|
||||
if node.get("type") == "decision":
|
||||
for opt in node.get("options", []):
|
||||
ref = opt.get("next_node_id", "")
|
||||
if ref and ref not in node_map:
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message=f"Option '{opt.get('label', '')}' in node '{nid}' references non-existent node '@{ref}'"
|
||||
))
|
||||
elif node.get("type") == "action":
|
||||
ref = node.get("next_node_id", "")
|
||||
if ref and ref not in node_map:
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message=f"Action node '{nid}' references non-existent next node '@{ref}'"
|
||||
))
|
||||
|
||||
root = node_map.get(root_id)
|
||||
return root, errors
|
||||
157
backend/app/services/tree_markdown_service.py
Normal file
157
backend/app/services/tree_markdown_service.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
JSONB → Markdown serializer for ResolutionFlow tree structures.
|
||||
|
||||
Converts a tree_structure JSONB dict into the ResolutionFlow Markdown format
|
||||
where each node is a block delimited by YAML frontmatter.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
|
||||
def serialize_tree_to_markdown(
|
||||
tree_structure: dict[str, Any],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Convert a tree structure JSONB dict to ResolutionFlow Markdown format.
|
||||
|
||||
Args:
|
||||
tree_structure: The recursive tree_structure dict from the database.
|
||||
metadata: Optional tree metadata (name, description, category, tags)
|
||||
to include as a frontmatter block at the top.
|
||||
|
||||
Returns:
|
||||
Markdown string with YAML frontmatter blocks for each node.
|
||||
"""
|
||||
blocks: list[str] = []
|
||||
|
||||
if metadata:
|
||||
meta_lines = ["---"]
|
||||
if metadata.get("name"):
|
||||
meta_lines.append(f"name: {metadata['name']}")
|
||||
if metadata.get("description"):
|
||||
meta_lines.append(f"description: {metadata['description']}")
|
||||
if metadata.get("category"):
|
||||
meta_lines.append(f"category: {metadata['category']}")
|
||||
tags = metadata.get("tags")
|
||||
if tags:
|
||||
tags_str = ", ".join(tags) if isinstance(tags, list) else str(tags)
|
||||
meta_lines.append(f"tags: [{tags_str}]")
|
||||
meta_lines.append("---")
|
||||
blocks.append("\n".join(meta_lines))
|
||||
|
||||
_serialize_node(tree_structure, parent_id=None, blocks=blocks)
|
||||
return "\n\n".join(blocks) + "\n"
|
||||
|
||||
|
||||
def _serialize_node(
|
||||
node: dict[str, Any],
|
||||
parent_id: str | None,
|
||||
blocks: list[str],
|
||||
) -> None:
|
||||
"""Recursively serialize a single node and its children."""
|
||||
node_type = node.get("type", "decision")
|
||||
node_id = node.get("id", "unknown")
|
||||
|
||||
# Build frontmatter
|
||||
frontmatter_lines = [
|
||||
"---",
|
||||
f"id: {node_id}",
|
||||
f"type: {node_type}",
|
||||
]
|
||||
if parent_id is not None:
|
||||
frontmatter_lines.append(f"parent: {parent_id}")
|
||||
frontmatter_lines.append("---")
|
||||
|
||||
# Build body based on node type
|
||||
body_lines: list[str] = []
|
||||
|
||||
if node_type == "decision":
|
||||
_serialize_decision(node, body_lines)
|
||||
elif node_type == "action":
|
||||
_serialize_action(node, body_lines)
|
||||
elif node_type == "solution":
|
||||
_serialize_solution(node, body_lines)
|
||||
|
||||
block = "\n".join(frontmatter_lines) + "\n" + "\n".join(body_lines)
|
||||
blocks.append(block)
|
||||
|
||||
# Recurse into children
|
||||
children = node.get("children", [])
|
||||
if children:
|
||||
for child in children:
|
||||
_serialize_node(child, parent_id=node_id, blocks=blocks)
|
||||
|
||||
|
||||
def _serialize_decision(node: dict[str, Any], lines: list[str]) -> None:
|
||||
"""Serialize a decision node body."""
|
||||
question = node.get("question", "")
|
||||
if question:
|
||||
lines.append(f"# {question}")
|
||||
lines.append("")
|
||||
|
||||
help_text = node.get("help_text", "")
|
||||
if help_text:
|
||||
for ht_line in help_text.split("\n"):
|
||||
lines.append(f"> {ht_line}")
|
||||
lines.append("")
|
||||
|
||||
options = node.get("options", [])
|
||||
for i, opt in enumerate(options):
|
||||
label = opt.get("label", "")
|
||||
next_id = opt.get("next_node_id", "")
|
||||
letter = chr(ord("A") + i) if i < 26 else str(i + 1)
|
||||
if next_id:
|
||||
lines.append(f"- [{letter}] {label} → @{next_id}")
|
||||
else:
|
||||
lines.append(f"- [{letter}] {label}")
|
||||
|
||||
|
||||
def _serialize_action(node: dict[str, Any], lines: list[str]) -> None:
|
||||
"""Serialize an action node body."""
|
||||
title = node.get("title", "")
|
||||
if title:
|
||||
lines.append(f"## {title}")
|
||||
lines.append("")
|
||||
|
||||
description = node.get("description", "")
|
||||
if description:
|
||||
lines.append(description)
|
||||
lines.append("")
|
||||
|
||||
commands = node.get("commands", [])
|
||||
if commands:
|
||||
lines.append("```commands")
|
||||
for cmd in commands:
|
||||
lines.append(cmd)
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
expected = node.get("expected_outcome", "")
|
||||
if expected:
|
||||
lines.append(f"**Expected:** {expected}")
|
||||
lines.append("")
|
||||
|
||||
next_id = node.get("next_node_id", "")
|
||||
if next_id:
|
||||
lines.append(f"→ @{next_id}")
|
||||
|
||||
|
||||
def _serialize_solution(node: dict[str, Any], lines: list[str]) -> None:
|
||||
"""Serialize a solution node body."""
|
||||
title = node.get("title", "")
|
||||
if title:
|
||||
lines.append(f"## {title}")
|
||||
lines.append("")
|
||||
|
||||
description = node.get("description", "")
|
||||
if description:
|
||||
lines.append(description)
|
||||
lines.append("")
|
||||
|
||||
steps = node.get("resolution_steps", [])
|
||||
if steps:
|
||||
for i, step in enumerate(steps, 1):
|
||||
lines.append(f"{i}. {step}")
|
||||
|
||||
solution = node.get("solution", "")
|
||||
if solution and not description and not steps:
|
||||
lines.append(solution)
|
||||
93
backend/app/services/tree_markdown_validator.py
Normal file
93
backend/app/services/tree_markdown_validator.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Validation for ResolutionFlow tree markdown.
|
||||
|
||||
Validates markdown without saving, returning detailed errors with line numbers.
|
||||
"""
|
||||
from app.services.tree_markdown_parser import parse_markdown_to_tree, ParseError
|
||||
|
||||
|
||||
def validate_tree_markdown(markdown: str) -> list[ParseError]:
|
||||
"""Validate tree markdown and return all errors/warnings.
|
||||
|
||||
This wraps the parser and adds additional semantic checks.
|
||||
|
||||
Args:
|
||||
markdown: The markdown string to validate.
|
||||
|
||||
Returns:
|
||||
List of ParseError objects with line, column, message, severity.
|
||||
"""
|
||||
result = parse_markdown_to_tree(markdown)
|
||||
errors = list(result.errors)
|
||||
|
||||
# If parsing completely failed, return early
|
||||
if result.tree_structure is None:
|
||||
return errors
|
||||
|
||||
# Additional semantic validation on the parsed tree
|
||||
_validate_tree_semantics(result.tree_structure, errors)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_tree_semantics(tree: dict, errors: list[ParseError]) -> None:
|
||||
"""Run semantic checks on a parsed tree structure."""
|
||||
all_ids: set[str] = set()
|
||||
has_solution = False
|
||||
|
||||
def _collect_ids(node: dict) -> None:
|
||||
nonlocal has_solution
|
||||
all_ids.add(node.get("id", ""))
|
||||
if node.get("type") == "solution":
|
||||
has_solution = True
|
||||
for child in node.get("children", []):
|
||||
_collect_ids(child)
|
||||
|
||||
_collect_ids(tree)
|
||||
|
||||
if not has_solution:
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message="Tree must have at least one solution node",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
# Check for empty required fields
|
||||
def _validate_node(node: dict) -> None:
|
||||
ntype = node.get("type", "")
|
||||
nid = node.get("id", "")
|
||||
|
||||
if ntype == "decision":
|
||||
if not node.get("question", "").strip():
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message=f"Decision node '{nid}' has an empty question",
|
||||
severity="warning"
|
||||
))
|
||||
if not node.get("options"):
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message=f"Decision node '{nid}' has no options",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
elif ntype == "action":
|
||||
if not node.get("title", "").strip():
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message=f"Action node '{nid}' has an empty title",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
elif ntype == "solution":
|
||||
if not node.get("title", "").strip():
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message=f"Solution node '{nid}' has an empty title",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
for child in node.get("children", []):
|
||||
_validate_node(child)
|
||||
|
||||
_validate_node(tree)
|
||||
134
backend/app/services/variable_service.py
Normal file
134
backend/app/services/variable_service.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Variable extraction and resolution for ResolutionFlow tree structures.
|
||||
|
||||
Supports three variable tokens:
|
||||
- [USER_INPUT:prompt] — prompts user for input during session navigation
|
||||
- [VAR:name] — references a previously saved variable value
|
||||
- [SAVE_AS:name] — saves the current context as a named variable
|
||||
"""
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class VariableDefinition:
|
||||
"""A variable found in a tree structure."""
|
||||
name: str
|
||||
kind: str # 'user_input', 'reference', 'save_as'
|
||||
prompt: str # For user_input: the prompt text; for others: empty
|
||||
node_id: str # The node where this variable appears
|
||||
|
||||
|
||||
# Regex patterns for variable tokens
|
||||
USER_INPUT_RE = re.compile(r'\[USER_INPUT:([^\]]+)\]')
|
||||
VAR_REF_RE = re.compile(r'\[VAR:([^\]]+)\]')
|
||||
SAVE_AS_RE = re.compile(r'\[SAVE_AS:([^\]]+)\]')
|
||||
|
||||
|
||||
def extract_variables(tree_structure: dict[str, Any]) -> list[VariableDefinition]:
|
||||
"""Extract all variable definitions from a tree structure.
|
||||
|
||||
Traverses the tree recursively and finds all [USER_INPUT:...],
|
||||
[VAR:...], and [SAVE_AS:...] tokens.
|
||||
|
||||
Args:
|
||||
tree_structure: The recursive tree_structure dict.
|
||||
|
||||
Returns:
|
||||
List of VariableDefinition objects found in the tree.
|
||||
"""
|
||||
variables: list[VariableDefinition] = []
|
||||
_scan_node(tree_structure, variables)
|
||||
return variables
|
||||
|
||||
|
||||
def _scan_node(node: dict[str, Any], variables: list[VariableDefinition]) -> None:
|
||||
"""Scan a node and its children for variable tokens."""
|
||||
node_id = node.get("id", "unknown")
|
||||
|
||||
# Collect all text fields to scan
|
||||
text_fields = [
|
||||
node.get("question", ""),
|
||||
node.get("title", ""),
|
||||
node.get("description", ""),
|
||||
node.get("help_text", ""),
|
||||
node.get("expected_outcome", ""),
|
||||
node.get("solution", ""),
|
||||
]
|
||||
|
||||
# Include option labels
|
||||
for opt in node.get("options", []):
|
||||
text_fields.append(opt.get("label", ""))
|
||||
|
||||
# Include resolution steps
|
||||
for step in node.get("resolution_steps", []):
|
||||
text_fields.append(step)
|
||||
|
||||
# Include commands
|
||||
for cmd in node.get("commands", []):
|
||||
text_fields.append(cmd)
|
||||
|
||||
# Scan all text fields
|
||||
for text in text_fields:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
for match in USER_INPUT_RE.finditer(text):
|
||||
variables.append(VariableDefinition(
|
||||
name=match.group(1).strip(),
|
||||
kind="user_input",
|
||||
prompt=match.group(1).strip(),
|
||||
node_id=node_id,
|
||||
))
|
||||
|
||||
for match in VAR_REF_RE.finditer(text):
|
||||
variables.append(VariableDefinition(
|
||||
name=match.group(1).strip(),
|
||||
kind="reference",
|
||||
prompt="",
|
||||
node_id=node_id,
|
||||
))
|
||||
|
||||
for match in SAVE_AS_RE.finditer(text):
|
||||
variables.append(VariableDefinition(
|
||||
name=match.group(1).strip(),
|
||||
kind="save_as",
|
||||
prompt="",
|
||||
node_id=node_id,
|
||||
))
|
||||
|
||||
# Recurse into children
|
||||
for child in node.get("children", []):
|
||||
_scan_node(child, variables)
|
||||
|
||||
|
||||
def resolve_variables(text: str, variables: dict[str, str]) -> str:
|
||||
"""Replace variable tokens in text with their resolved values.
|
||||
|
||||
- [VAR:name] → replaced with variables[name] if present
|
||||
- [USER_INPUT:prompt] → replaced with variables[prompt] if present
|
||||
- [SAVE_AS:name] → removed (save directives don't appear in output)
|
||||
|
||||
Args:
|
||||
text: The text containing variable tokens.
|
||||
variables: Dict mapping variable names to their values.
|
||||
|
||||
Returns:
|
||||
Text with variable tokens replaced.
|
||||
"""
|
||||
def _replace_var(match: re.Match) -> str:
|
||||
name = match.group(1).strip()
|
||||
return variables.get(name, f"[VAR:{name}]")
|
||||
|
||||
def _replace_input(match: re.Match) -> str:
|
||||
prompt = match.group(1).strip()
|
||||
return variables.get(prompt, f"[USER_INPUT:{prompt}]")
|
||||
|
||||
def _replace_save(match: re.Match) -> str:
|
||||
return "" # Save directives are removed from output
|
||||
|
||||
result = VAR_REF_RE.sub(_replace_var, text)
|
||||
result = USER_INPUT_RE.sub(_replace_input, result)
|
||||
result = SAVE_AS_RE.sub(_replace_save, result)
|
||||
return result
|
||||
595
backend/tests/test_tree_markdown.py
Normal file
595
backend/tests/test_tree_markdown.py
Normal file
@@ -0,0 +1,595 @@
|
||||
"""
|
||||
Tests for tree markdown serialization, parsing, and round-trip fidelity.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.services.tree_markdown_service import serialize_tree_to_markdown
|
||||
from app.services.tree_markdown_parser import parse_markdown_to_tree
|
||||
from app.services.tree_markdown_validator import validate_tree_markdown
|
||||
|
||||
|
||||
# --- Fixtures: Tree structures ---
|
||||
|
||||
SIMPLE_TREE = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Is this a test?",
|
||||
"help_text": "Check if this is a test scenario",
|
||||
"options": [
|
||||
{"id": "yes", "label": "Yes", "next_node_id": "solution1"},
|
||||
{"id": "no", "label": "No", "next_node_id": "solution2"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "solution1",
|
||||
"type": "solution",
|
||||
"title": "Test Confirmed",
|
||||
"description": "This is indeed a test.",
|
||||
"resolution_steps": ["Step 1", "Step 2"],
|
||||
"solution": "Test confirmed",
|
||||
},
|
||||
{
|
||||
"id": "solution2",
|
||||
"type": "solution",
|
||||
"title": "Not a Test",
|
||||
"description": "This is not a test.",
|
||||
"solution": "Not a test",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
ACTION_TREE = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "What type of issue?",
|
||||
"help_text": "Identify the primary issue",
|
||||
"options": [
|
||||
{"id": "opt_net", "label": "Network issue", "next_node_id": "check_net"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "check_net",
|
||||
"type": "action",
|
||||
"title": "Run Network Diagnostics",
|
||||
"description": "Check basic connectivity.",
|
||||
"commands": ["ping 8.8.8.8", "tracert gateway.local"],
|
||||
"expected_outcome": "Ping replies or timeout",
|
||||
"next_node_id": "resolved",
|
||||
"children": [
|
||||
{
|
||||
"id": "resolved",
|
||||
"type": "solution",
|
||||
"title": "Issue Resolved",
|
||||
"description": "Network is working now.",
|
||||
"solution": "Issue resolved",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
DEEPLY_NESTED_TREE = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Level 1?",
|
||||
"options": [
|
||||
{"id": "o1", "label": "Go deeper", "next_node_id": "level2"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "level2",
|
||||
"type": "decision",
|
||||
"question": "Level 2?",
|
||||
"options": [
|
||||
{"id": "o2", "label": "Go deeper", "next_node_id": "level3"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "level3",
|
||||
"type": "decision",
|
||||
"question": "Level 3?",
|
||||
"options": [
|
||||
{"id": "o3", "label": "Done", "next_node_id": "final"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "final",
|
||||
"type": "solution",
|
||||
"title": "Deep Solution",
|
||||
"description": "Found it at level 3.",
|
||||
"solution": "Deep solution found",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
SINGLE_NODE_TREE = {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
"title": "Quick Fix",
|
||||
"description": "Just restart the computer.",
|
||||
"solution": "Restart the computer",
|
||||
}
|
||||
|
||||
|
||||
# --- Serialization Tests ---
|
||||
|
||||
class TestSerializeTreeToMarkdown:
|
||||
def test_simple_tree(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
assert "id: root" in md
|
||||
assert "type: decision" in md
|
||||
assert "# Is this a test?" in md
|
||||
assert "> Check if this is a test scenario" in md
|
||||
assert "- [A] Yes → @solution1" in md
|
||||
assert "- [B] No → @solution2" in md
|
||||
assert "id: solution1" in md
|
||||
assert "## Test Confirmed" in md
|
||||
assert "1. Step 1" in md
|
||||
assert "2. Step 2" in md
|
||||
|
||||
def test_action_tree(self):
|
||||
md = serialize_tree_to_markdown(ACTION_TREE)
|
||||
assert "## Run Network Diagnostics" in md
|
||||
assert "```commands" in md
|
||||
assert "ping 8.8.8.8" in md
|
||||
assert "**Expected:** Ping replies or timeout" in md
|
||||
assert "→ @resolved" in md
|
||||
|
||||
def test_deeply_nested(self):
|
||||
md = serialize_tree_to_markdown(DEEPLY_NESTED_TREE)
|
||||
assert "id: level2" in md
|
||||
assert "parent: root" in md
|
||||
assert "id: level3" in md
|
||||
assert "parent: level2" in md
|
||||
assert "id: final" in md
|
||||
assert "parent: level3" in md
|
||||
|
||||
def test_single_node(self):
|
||||
md = serialize_tree_to_markdown(SINGLE_NODE_TREE)
|
||||
assert "id: root" in md
|
||||
assert "type: solution" in md
|
||||
assert "## Quick Fix" in md
|
||||
|
||||
def test_root_has_no_parent(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
lines = md.split("\n")
|
||||
# Find the first frontmatter block
|
||||
in_first_block = False
|
||||
for line in lines:
|
||||
if line.strip() == "---":
|
||||
if not in_first_block:
|
||||
in_first_block = True
|
||||
continue
|
||||
else:
|
||||
break
|
||||
if in_first_block:
|
||||
assert not line.startswith("parent:"), "Root node should not have parent"
|
||||
|
||||
|
||||
# --- Parsing Tests ---
|
||||
|
||||
class TestParseMarkdownToTree:
|
||||
def test_simple_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
tree = result.tree_structure
|
||||
assert tree["id"] == "root"
|
||||
assert tree["type"] == "decision"
|
||||
assert tree["question"] == "Is this a test?"
|
||||
assert len(tree["options"]) == 2
|
||||
assert tree["options"][0]["label"] == "Yes"
|
||||
assert tree["options"][0]["next_node_id"] == "solution1"
|
||||
assert len(tree["children"]) == 2
|
||||
|
||||
def test_action_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(ACTION_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
tree = result.tree_structure
|
||||
# Find the action node
|
||||
action = tree["children"][0]
|
||||
assert action["type"] == "action"
|
||||
assert action["title"] == "Run Network Diagnostics"
|
||||
assert action["commands"] == ["ping 8.8.8.8", "tracert gateway.local"]
|
||||
assert action["expected_outcome"] == "Ping replies or timeout"
|
||||
assert action["next_node_id"] == "resolved"
|
||||
# Action should have children
|
||||
assert len(action["children"]) == 1
|
||||
assert action["children"][0]["type"] == "solution"
|
||||
|
||||
def test_deeply_nested_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(DEEPLY_NESTED_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
tree = result.tree_structure
|
||||
assert tree["id"] == "root"
|
||||
level2 = tree["children"][0]
|
||||
assert level2["id"] == "level2"
|
||||
level3 = level2["children"][0]
|
||||
assert level3["id"] == "level3"
|
||||
final = level3["children"][0]
|
||||
assert final["id"] == "final"
|
||||
assert final["type"] == "solution"
|
||||
|
||||
def test_single_node_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(SINGLE_NODE_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
tree = result.tree_structure
|
||||
assert tree["id"] == "root"
|
||||
assert tree["type"] == "solution"
|
||||
assert tree["title"] == "Quick Fix"
|
||||
|
||||
def test_empty_markdown(self):
|
||||
result = parse_markdown_to_tree("")
|
||||
assert result.tree_structure is None
|
||||
assert len(result.errors) > 0
|
||||
|
||||
def test_malformed_frontmatter(self):
|
||||
md = "---\nno_id_here: true\n---\n# Some question"
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert any("missing 'id'" in e.message.lower() or "missing" in e.message.lower()
|
||||
for e in result.errors)
|
||||
|
||||
def test_invalid_type(self):
|
||||
md = "---\nid: root\ntype: invalid_type\n---\n# Question"
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert any("invalid node type" in e.message.lower() for e in result.errors)
|
||||
|
||||
def test_duplicate_ids(self):
|
||||
md = (
|
||||
"---\nid: root\ntype: decision\n---\n# Q1\n- [A] Opt → @dup\n\n"
|
||||
"---\nid: dup\ntype: solution\nparent: root\n---\n## Sol1\n\n"
|
||||
"---\nid: dup\ntype: solution\nparent: root\n---\n## Sol2\n"
|
||||
)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert any("duplicate" in e.message.lower() for e in result.errors)
|
||||
|
||||
def test_broken_reference(self):
|
||||
md = (
|
||||
"---\nid: root\ntype: decision\n---\n"
|
||||
"# Which issue?\n"
|
||||
"- [A] Network → @nonexistent\n"
|
||||
)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert any("non-existent" in e.message.lower() for e in result.errors)
|
||||
|
||||
def test_orphaned_parent_reference(self):
|
||||
md = (
|
||||
"---\nid: root\ntype: decision\n---\n# Q\n- [A] Opt → @child\n\n"
|
||||
"---\nid: child\ntype: solution\nparent: nonexistent_parent\n---\n## Sol\n"
|
||||
)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert any("non-existent parent" in e.message.lower() for e in result.errors)
|
||||
|
||||
def test_resolution_steps_parsed(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
tree = result.tree_structure
|
||||
# solution1 should have resolution_steps
|
||||
sol1 = tree["children"][0]
|
||||
assert sol1["id"] == "solution1"
|
||||
assert sol1["resolution_steps"] == ["Step 1", "Step 2"]
|
||||
|
||||
def test_help_text_preserved(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
tree = result.tree_structure
|
||||
assert tree["help_text"] == "Check if this is a test scenario"
|
||||
|
||||
def test_description_with_markdown(self):
|
||||
"""Test that markdown content in descriptions is preserved."""
|
||||
tree = {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
"title": "Complex Solution",
|
||||
"description": "Use **bold** and *italic* and `code`.\n\nMultiple paragraphs too.",
|
||||
"solution": "Complex solution",
|
||||
}
|
||||
md = serialize_tree_to_markdown(tree)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
assert "**bold**" in result.tree_structure["description"]
|
||||
assert "`code`" in result.tree_structure["description"]
|
||||
|
||||
|
||||
# --- Validation Tests ---
|
||||
|
||||
class TestValidateTreeMarkdown:
|
||||
def test_valid_tree_no_errors(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
errors = validate_tree_markdown(md)
|
||||
hard_errors = [e for e in errors if e.severity == "error"]
|
||||
assert len(hard_errors) == 0
|
||||
|
||||
def test_empty_markdown_errors(self):
|
||||
errors = validate_tree_markdown("")
|
||||
assert len(errors) > 0
|
||||
|
||||
def test_missing_solution_warning(self):
|
||||
tree = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Question?",
|
||||
"options": [],
|
||||
"children": [],
|
||||
}
|
||||
md = serialize_tree_to_markdown(tree)
|
||||
errors = validate_tree_markdown(md)
|
||||
warnings = [e for e in errors if e.severity == "warning"]
|
||||
assert any("solution" in e.message.lower() for e in warnings)
|
||||
|
||||
def test_empty_question_warning(self):
|
||||
tree = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "",
|
||||
"options": [],
|
||||
"children": [],
|
||||
}
|
||||
md = serialize_tree_to_markdown(tree)
|
||||
errors = validate_tree_markdown(md)
|
||||
assert any("empty question" in e.message.lower() for e in errors)
|
||||
|
||||
|
||||
# --- Metadata Tests ---
|
||||
|
||||
class TestMetadataBlocks:
|
||||
def test_serialize_with_metadata(self):
|
||||
metadata = {"name": "Test Tree", "description": "A test", "category": "Help Desk", "tags": ["dns", "network"]}
|
||||
md = serialize_tree_to_markdown(SINGLE_NODE_TREE, metadata=metadata)
|
||||
assert "name: Test Tree" in md
|
||||
assert "description: A test" in md
|
||||
assert "category: Help Desk" in md
|
||||
assert "tags: [dns, network]" in md
|
||||
# Node should still be present
|
||||
assert "id: root" in md
|
||||
|
||||
def test_parse_with_metadata(self):
|
||||
md = (
|
||||
"---\nname: My Tree\ndescription: desc here\ncategory: Help Desk\ntags: [dns, vpn]\n---\n\n"
|
||||
"---\nid: root\ntype: solution\n---\n## Quick Fix\n"
|
||||
)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.metadata is not None
|
||||
assert result.metadata["name"] == "My Tree"
|
||||
assert result.metadata["description"] == "desc here"
|
||||
assert result.metadata["category"] == "Help Desk"
|
||||
assert result.metadata["tags"] == ["dns", "vpn"]
|
||||
assert result.tree_structure is not None
|
||||
assert result.tree_structure["id"] == "root"
|
||||
|
||||
def test_parse_without_metadata(self):
|
||||
md = "---\nid: root\ntype: solution\n---\n## Quick Fix\n"
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.metadata is None
|
||||
assert result.tree_structure is not None
|
||||
|
||||
def test_metadata_roundtrip(self):
|
||||
metadata = {"name": "Roundtrip Tree", "description": "Tests roundtrip", "tags": ["test"]}
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE, metadata=metadata)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.metadata is not None
|
||||
assert result.metadata["name"] == "Roundtrip Tree"
|
||||
assert result.metadata["description"] == "Tests roundtrip"
|
||||
assert result.metadata["tags"] == ["test"]
|
||||
assert result.tree_structure is not None
|
||||
assert result.tree_structure["id"] == "root"
|
||||
|
||||
def test_metadata_only_no_nodes(self):
|
||||
md = "---\nname: Just Metadata\n---\n"
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.metadata is not None
|
||||
assert result.metadata["name"] == "Just Metadata"
|
||||
assert result.tree_structure is None
|
||||
assert any("no node blocks" in e.message.lower() for e in result.errors)
|
||||
|
||||
|
||||
# --- Round-Trip Fidelity Tests ---
|
||||
|
||||
class TestRoundTripFidelity:
|
||||
"""Ensure serialize → parse produces semantically identical trees."""
|
||||
|
||||
def _assert_node_equal(self, original: dict, parsed: dict, path: str = "root"):
|
||||
"""Deep compare two node dicts, ignoring internal fields and option IDs."""
|
||||
assert original["id"] == parsed["id"], f"{path}: id mismatch"
|
||||
assert original["type"] == parsed["type"], f"{path}: type mismatch"
|
||||
|
||||
if original["type"] == "decision":
|
||||
assert original.get("question", "") == parsed.get("question", ""), \
|
||||
f"{path}: question mismatch"
|
||||
# Compare option labels and next_node_ids (not auto-generated option IDs)
|
||||
orig_opts = original.get("options", [])
|
||||
parsed_opts = parsed.get("options", [])
|
||||
assert len(orig_opts) == len(parsed_opts), \
|
||||
f"{path}: options count mismatch ({len(orig_opts)} vs {len(parsed_opts)})"
|
||||
for j, (oo, po) in enumerate(zip(orig_opts, parsed_opts)):
|
||||
assert oo["label"] == po["label"], \
|
||||
f"{path}.options[{j}]: label mismatch"
|
||||
assert oo.get("next_node_id", "") == po.get("next_node_id", ""), \
|
||||
f"{path}.options[{j}]: next_node_id mismatch"
|
||||
|
||||
elif original["type"] == "action":
|
||||
assert original.get("title", "") == parsed.get("title", ""), \
|
||||
f"{path}: title mismatch"
|
||||
assert original.get("commands", []) == parsed.get("commands", []), \
|
||||
f"{path}: commands mismatch"
|
||||
assert original.get("expected_outcome", "") == parsed.get("expected_outcome", ""), \
|
||||
f"{path}: expected_outcome mismatch"
|
||||
assert original.get("next_node_id", "") == parsed.get("next_node_id", ""), \
|
||||
f"{path}: next_node_id mismatch"
|
||||
|
||||
elif original["type"] == "solution":
|
||||
assert original.get("title", "") == parsed.get("title", ""), \
|
||||
f"{path}: title mismatch"
|
||||
assert original.get("resolution_steps", []) == parsed.get("resolution_steps", []), \
|
||||
f"{path}: resolution_steps mismatch"
|
||||
|
||||
# Compare children recursively
|
||||
orig_children = original.get("children", [])
|
||||
parsed_children = parsed.get("children", [])
|
||||
assert len(orig_children) == len(parsed_children), \
|
||||
f"{path}: children count mismatch ({len(orig_children)} vs {len(parsed_children)})"
|
||||
for i, (oc, pc) in enumerate(zip(orig_children, parsed_children)):
|
||||
self._assert_node_equal(oc, pc, f"{path}.children[{i}]")
|
||||
|
||||
def test_simple_tree_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
self._assert_node_equal(SIMPLE_TREE, result.tree_structure)
|
||||
|
||||
def test_action_tree_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(ACTION_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
self._assert_node_equal(ACTION_TREE, result.tree_structure)
|
||||
|
||||
def test_deeply_nested_roundtrip(self):
|
||||
md = serialize_tree_to_markdown(DEEPLY_NESTED_TREE)
|
||||
result = parse_markdown_to_tree(md)
|
||||
assert result.tree_structure is not None
|
||||
self._assert_node_equal(DEEPLY_NESTED_TREE, result.tree_structure)
|
||||
|
||||
def test_double_roundtrip(self):
|
||||
"""serialize → parse → serialize should produce same markdown."""
|
||||
md1 = serialize_tree_to_markdown(SIMPLE_TREE)
|
||||
result1 = parse_markdown_to_tree(md1)
|
||||
assert result1.tree_structure is not None
|
||||
md2 = serialize_tree_to_markdown(result1.tree_structure)
|
||||
result2 = parse_markdown_to_tree(md2)
|
||||
assert result2.tree_structure is not None
|
||||
self._assert_node_equal(result1.tree_structure, result2.tree_structure)
|
||||
|
||||
|
||||
# --- API Integration Tests ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTreeMarkdownAPI:
|
||||
async def test_export_markdown(self, client, auth_headers, test_tree):
|
||||
tree_id = test_tree["id"]
|
||||
response = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "markdown" in data
|
||||
# Should include metadata block
|
||||
assert "name:" in data["markdown"]
|
||||
# Should include node content
|
||||
assert "id: root" in data["markdown"]
|
||||
assert "# Is this a test?" in data["markdown"]
|
||||
|
||||
async def test_export_not_found(self, client, auth_headers):
|
||||
import uuid
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await client.get(
|
||||
f"/api/v1/trees/{fake_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_export_requires_auth(self, client, test_tree):
|
||||
tree_id = test_tree["id"]
|
||||
response = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_import_markdown(self, client, auth_headers, test_tree):
|
||||
tree_id = test_tree["id"]
|
||||
|
||||
# Export first
|
||||
export_resp = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
markdown = export_resp.json()["markdown"]
|
||||
|
||||
# Import back
|
||||
response = await client.put(
|
||||
f"/api/v1/trees/{tree_id}/import-markdown",
|
||||
json={"markdown": markdown},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
assert data["tree_structure"] is not None
|
||||
|
||||
async def test_import_invalid_markdown(self, client, auth_headers, test_tree):
|
||||
tree_id = test_tree["id"]
|
||||
response = await client.put(
|
||||
f"/api/v1/trees/{tree_id}/import-markdown",
|
||||
json={"markdown": "this is not valid tree markdown"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is False
|
||||
assert len(data["errors"]) > 0
|
||||
|
||||
async def test_validate_markdown(self, client, auth_headers, test_tree):
|
||||
tree_id = test_tree["id"]
|
||||
|
||||
# Export first
|
||||
export_resp = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
markdown = export_resp.json()["markdown"]
|
||||
|
||||
# Validate
|
||||
response = await client.post(
|
||||
"/api/v1/trees/validate-markdown",
|
||||
json={"markdown": markdown},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
assert data["tree_structure"] is not None
|
||||
# Should return parsed metadata from export
|
||||
assert data["metadata"] is not None
|
||||
|
||||
async def test_validate_requires_auth(self, client):
|
||||
response = await client.post(
|
||||
"/api/v1/trees/validate-markdown",
|
||||
json={"markdown": "---\nid: root\ntype: solution\n---\n## Fix\n"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_roundtrip_via_api(self, client, auth_headers, test_tree):
|
||||
"""Export tree → import same markdown → verify tree unchanged."""
|
||||
tree_id = test_tree["id"]
|
||||
|
||||
# Export
|
||||
export_resp = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
markdown = export_resp.json()["markdown"]
|
||||
|
||||
# Import
|
||||
import_resp = await client.put(
|
||||
f"/api/v1/trees/{tree_id}/import-markdown",
|
||||
json={"markdown": markdown},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert import_resp.json()["valid"] is True
|
||||
|
||||
# Re-export and compare
|
||||
export_resp2 = await client.get(
|
||||
f"/api/v1/trees/{tree_id}/export-markdown",
|
||||
headers=auth_headers,
|
||||
)
|
||||
markdown2 = export_resp2.json()["markdown"]
|
||||
|
||||
# Should be identical
|
||||
assert markdown == markdown2
|
||||
119
backend/tests/test_variable_service.py
Normal file
119
backend/tests/test_variable_service.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Tests for variable extraction and resolution service."""
|
||||
import pytest
|
||||
|
||||
from app.services.variable_service import extract_variables, resolve_variables
|
||||
|
||||
|
||||
TREE_WITH_VARIABLES = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "What is the hostname of [USER_INPUT:hostname]?",
|
||||
"help_text": "Enter the server hostname",
|
||||
"options": [
|
||||
{"id": "opt1", "label": "Check [VAR:hostname]", "next_node_id": "action1"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "action1",
|
||||
"type": "action",
|
||||
"title": "Ping [VAR:hostname]",
|
||||
"description": "Run diagnostics on [VAR:hostname].\n\n[SAVE_AS:test_results]",
|
||||
"commands": ["ping [VAR:hostname]"],
|
||||
"expected_outcome": "Replies from [VAR:hostname]",
|
||||
"next_node_id": "solution1",
|
||||
"children": [
|
||||
{
|
||||
"id": "solution1",
|
||||
"type": "solution",
|
||||
"title": "Resolved",
|
||||
"description": "Issue on [VAR:hostname] resolved.",
|
||||
"resolution_steps": [
|
||||
"Document results for [VAR:hostname]",
|
||||
"Save as [SAVE_AS:final_result]"
|
||||
],
|
||||
"solution": "Resolved",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
TREE_WITHOUT_VARIABLES = {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
"title": "Simple Fix",
|
||||
"description": "Just restart it.",
|
||||
"solution": "Restart",
|
||||
}
|
||||
|
||||
|
||||
class TestExtractVariables:
|
||||
def test_extracts_user_input(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
user_inputs = [v for v in variables if v.kind == "user_input"]
|
||||
assert len(user_inputs) == 1
|
||||
assert user_inputs[0].name == "hostname"
|
||||
assert user_inputs[0].node_id == "root"
|
||||
|
||||
def test_extracts_var_references(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
refs = [v for v in variables if v.kind == "reference"]
|
||||
assert len(refs) >= 4 # hostname used in multiple places
|
||||
|
||||
def test_extracts_save_as(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
saves = [v for v in variables if v.kind == "save_as"]
|
||||
assert len(saves) == 2
|
||||
names = {v.name for v in saves}
|
||||
assert "test_results" in names
|
||||
assert "final_result" in names
|
||||
|
||||
def test_no_variables(self):
|
||||
variables = extract_variables(TREE_WITHOUT_VARIABLES)
|
||||
assert len(variables) == 0
|
||||
|
||||
def test_extracts_from_commands(self):
|
||||
variables = extract_variables(TREE_WITH_VARIABLES)
|
||||
cmd_vars = [v for v in variables if v.node_id == "action1" and v.kind == "reference"]
|
||||
# hostname in title, description, commands, expected_outcome
|
||||
assert len(cmd_vars) >= 3
|
||||
|
||||
|
||||
class TestResolveVariables:
|
||||
def test_resolves_var_reference(self):
|
||||
text = "Ping [VAR:hostname] now"
|
||||
result = resolve_variables(text, {"hostname": "server01"})
|
||||
assert result == "Ping server01 now"
|
||||
|
||||
def test_resolves_user_input(self):
|
||||
text = "Server: [USER_INPUT:hostname]"
|
||||
result = resolve_variables(text, {"hostname": "server01"})
|
||||
assert result == "Server: server01"
|
||||
|
||||
def test_removes_save_as(self):
|
||||
text = "Done [SAVE_AS:result] here"
|
||||
result = resolve_variables(text, {})
|
||||
assert result == "Done here"
|
||||
|
||||
def test_unresolved_var_preserved(self):
|
||||
text = "Server: [VAR:unknown_var]"
|
||||
result = resolve_variables(text, {})
|
||||
assert result == "Server: [VAR:unknown_var]"
|
||||
|
||||
def test_multiple_variables(self):
|
||||
text = "[VAR:hostname] ([VAR:ip]) - [USER_INPUT:ticket]"
|
||||
result = resolve_variables(text, {
|
||||
"hostname": "server01",
|
||||
"ip": "10.0.0.1",
|
||||
"ticket": "INC001",
|
||||
})
|
||||
assert result == "server01 (10.0.0.1) - INC001"
|
||||
|
||||
def test_empty_variables_dict(self):
|
||||
text = "No vars here"
|
||||
result = resolve_variables(text, {})
|
||||
assert result == "No vars here"
|
||||
|
||||
def test_empty_text(self):
|
||||
result = resolve_variables("", {"hostname": "server01"})
|
||||
assert result == ""
|
||||
72
frontend/package-lock.json
generated
72
frontend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"@types/lodash": "^4.17.23",
|
||||
"axios": "^1.13.4",
|
||||
@@ -1338,6 +1339,29 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2000,6 +2024,14 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -3231,6 +3263,16 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -4567,6 +4609,19 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -5259,6 +5314,17 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.55.1",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -6276,6 +6342,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"@types/lodash": "^4.17.23",
|
||||
"axios": "^1.13.4",
|
||||
|
||||
@@ -10,3 +10,4 @@ export { default as stepsApi } from './steps'
|
||||
export { default as stepCategoriesApi } from './stepCategories'
|
||||
export { default as accountsApi } from './accounts'
|
||||
export { default as adminApi } from './admin'
|
||||
export { treeMarkdownApi } from './treeMarkdown'
|
||||
|
||||
22
frontend/src/api/treeMarkdown.ts
Normal file
22
frontend/src/api/treeMarkdown.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import api from '@/api/client'
|
||||
import type { TreeMarkdownValidation } from '@/types'
|
||||
|
||||
export const treeMarkdownApi = {
|
||||
/** Export a tree's JSONB structure as ResolutionFlow markdown */
|
||||
exportMarkdown: async (treeId: string): Promise<{ markdown: string }> => {
|
||||
const response = await api.get(`/trees/${treeId}/export-markdown`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Parse markdown and update a tree's JSONB structure */
|
||||
importMarkdown: async (treeId: string, markdown: string): Promise<TreeMarkdownValidation> => {
|
||||
const response = await api.put(`/trees/${treeId}/import-markdown`, { markdown })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Validate markdown without saving */
|
||||
validateMarkdown: async (markdown: string): Promise<TreeMarkdownValidation> => {
|
||||
const response = await api.post('/trees/validate-markdown', { markdown })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
73
frontend/src/components/session/VariablePromptModal.tsx
Normal file
73
frontend/src/components/session/VariablePromptModal.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface VariablePromptModalProps {
|
||||
/** The prompt text from [USER_INPUT:prompt] */
|
||||
prompt: string
|
||||
/** Called with the user's input value */
|
||||
onSubmit: (value: string) => void
|
||||
/** Called when user cancels */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function VariablePromptModal({ prompt, onSubmit, onCancel }: VariablePromptModalProps) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (value.trim()) {
|
||||
onSubmit(value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="glass-card w-full max-w-md rounded-2xl p-6 shadow-lg">
|
||||
<h2 className="mb-1 text-lg font-semibold text-white">Input Required</h2>
|
||||
<p className="mb-4 text-sm text-white/40">
|
||||
This step requires you to provide a value.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="mb-2 block text-sm font-medium text-white/70">
|
||||
{prompt}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Enter value..."
|
||||
autoFocus
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim()}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={cn(
|
||||
'rounded-lg border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { TreeMetadataForm } from './TreeMetadataForm'
|
||||
import { NodeList } from './NodeList'
|
||||
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Lazy load CodeModeEditor (Monaco is ~2MB)
|
||||
const CodeModeEditor = lazy(() =>
|
||||
import('./code-mode/CodeModeEditor').then(m => ({ default: m.CodeModeEditor }))
|
||||
)
|
||||
|
||||
interface TreeEditorLayoutProps {
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
const editorMode = useTreeEditorStore(s => s.editorMode)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -15,28 +24,52 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
isMobile ? 'flex-col' : 'flex-row'
|
||||
)}
|
||||
>
|
||||
{/* Left Panel - Form Editor */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col overflow-y-auto border-white/[0.06]',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4 p-4">
|
||||
<TreeMetadataForm />
|
||||
<NodeList />
|
||||
</div>
|
||||
</div>
|
||||
{editorMode === 'code' ? (
|
||||
<>
|
||||
{/* Code Mode: Monaco editor (60%) + Preview (40%) */}
|
||||
<div className={cn(
|
||||
'flex flex-col overflow-hidden border-white/[0.06]',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
)}>
|
||||
<Suspense fallback={
|
||||
<div className="flex h-full items-center justify-center bg-[#0a0a0a]">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white/20 border-t-white" />
|
||||
</div>
|
||||
}>
|
||||
<CodeModeEditor />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Preview */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-hidden bg-white/[0.02]',
|
||||
isMobile ? 'hidden' : 'block'
|
||||
)}
|
||||
>
|
||||
<TreePreviewPanel />
|
||||
</div>
|
||||
{/* Right Panel - Preview */}
|
||||
<div className={cn(
|
||||
'flex-1 overflow-hidden bg-white/[0.02]',
|
||||
isMobile ? 'hidden' : 'block'
|
||||
)}>
|
||||
<TreePreviewPanel />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Flow Mode: Form editor (60%) + Preview (40%) */}
|
||||
<div className={cn(
|
||||
'flex flex-col overflow-y-auto border-white/[0.06]',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
)}>
|
||||
<div className="space-y-4 p-4">
|
||||
<TreeMetadataForm />
|
||||
<NodeList />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Preview */}
|
||||
<div className={cn(
|
||||
'flex-1 overflow-hidden bg-white/[0.02]',
|
||||
isMobile ? 'hidden' : 'block'
|
||||
)}>
|
||||
<TreePreviewPanel />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
201
frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx
Normal file
201
frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Editor, { type OnMount, type BeforeMount } from '@monaco-editor/react'
|
||||
import type { editor as MonacoEditor } from 'monaco-editor'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import { treeMarkdownApi } from '@/api/treeMarkdown'
|
||||
import { resolutionFlowLanguage, LANGUAGE_ID } from './resolutionFlowLanguage'
|
||||
import { resolutionFlowTheme, THEME_ID } from './resolutionFlowTheme'
|
||||
import { createCompletionProvider } from './resolutionFlowCompletions'
|
||||
import { CodeModeToolbar } from './CodeModeToolbar'
|
||||
import { SyntaxHelpPanel } from './SyntaxHelpPanel'
|
||||
|
||||
// Module-level ref so TreeEditorPage can trigger Monaco undo/redo from toolbar buttons
|
||||
let _monacoEditor: MonacoEditor.IStandaloneCodeEditor | null = null
|
||||
export function getMonacoEditor() { return _monacoEditor }
|
||||
|
||||
export function CodeModeEditor() {
|
||||
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null)
|
||||
const validationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const {
|
||||
markdownSource,
|
||||
markdownValidationErrors,
|
||||
isMarkdownValid,
|
||||
isValidating,
|
||||
setMarkdownSource,
|
||||
setMarkdownValidationResult,
|
||||
getAvailableTargetNodes,
|
||||
} = useTreeEditorStore()
|
||||
|
||||
const [syntaxHelpOpen, setSyntaxHelpOpen] = useState(false)
|
||||
|
||||
// Register language and theme before editor mounts
|
||||
const handleEditorWillMount: BeforeMount = useCallback((monaco) => {
|
||||
// Register language if not already registered
|
||||
const langs = monaco.languages.getLanguages()
|
||||
if (!langs.some((l: { id: string }) => l.id === LANGUAGE_ID)) {
|
||||
monaco.languages.register({ id: LANGUAGE_ID })
|
||||
monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, resolutionFlowLanguage)
|
||||
}
|
||||
|
||||
// Register theme
|
||||
monaco.editor.defineTheme(THEME_ID, resolutionFlowTheme)
|
||||
|
||||
// Register completion provider
|
||||
monaco.languages.registerCompletionItemProvider(
|
||||
LANGUAGE_ID,
|
||||
createCompletionProvider(() =>
|
||||
getAvailableTargetNodes().map(n => ({
|
||||
id: n.id,
|
||||
label: n.label,
|
||||
type: n.type,
|
||||
}))
|
||||
)
|
||||
)
|
||||
}, [getAvailableTargetNodes])
|
||||
|
||||
// Editor mounted
|
||||
const handleEditorDidMount: OnMount = useCallback((editor) => {
|
||||
editorRef.current = editor
|
||||
_monacoEditor = editor
|
||||
editor.focus()
|
||||
}, [])
|
||||
|
||||
// Debounced validation on change
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
const md = value ?? ''
|
||||
setMarkdownSource(md)
|
||||
|
||||
// Cancel pending validation
|
||||
if (validationTimeoutRef.current) {
|
||||
clearTimeout(validationTimeoutRef.current)
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
// Debounce 800ms
|
||||
validationTimeoutRef.current = setTimeout(async () => {
|
||||
abortControllerRef.current = new AbortController()
|
||||
try {
|
||||
const result = await treeMarkdownApi.validateMarkdown(md)
|
||||
setMarkdownValidationResult(result)
|
||||
|
||||
// Set Monaco markers
|
||||
if (editorRef.current) {
|
||||
const monaco = (await import('monaco-editor')).default ?? await import('monaco-editor')
|
||||
const model = editorRef.current.getModel()
|
||||
if (model && monaco.editor?.setModelMarkers) {
|
||||
const markers = result.errors.map((err) => ({
|
||||
startLineNumber: err.line,
|
||||
startColumn: err.column,
|
||||
endLineNumber: err.line,
|
||||
endColumn: err.column + 1,
|
||||
message: err.message,
|
||||
severity: err.severity === 'error' ? 8 : 4, // MarkerSeverity.Error : Warning
|
||||
}))
|
||||
monaco.editor.setModelMarkers(model, 'resolutionflow', markers)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Validation cancelled or failed — ignore
|
||||
}
|
||||
}, 800)
|
||||
}, [setMarkdownSource, setMarkdownValidationResult])
|
||||
|
||||
// Insert template at cursor
|
||||
const handleInsertTemplate = useCallback((template: string) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
const position = editor.getPosition()
|
||||
if (!position) return
|
||||
|
||||
const model = editor.getModel()
|
||||
if (!model) return
|
||||
|
||||
// Insert at end of document
|
||||
const lastLine = model.getLineCount()
|
||||
const lastColumn = model.getLineMaxColumn(lastLine)
|
||||
|
||||
editor.executeEdits('insert-template', [{
|
||||
range: {
|
||||
startLineNumber: lastLine,
|
||||
startColumn: lastColumn,
|
||||
endLineNumber: lastLine,
|
||||
endColumn: lastColumn,
|
||||
},
|
||||
text: template,
|
||||
}])
|
||||
|
||||
// Move cursor to the inserted template
|
||||
const newLastLine = model.getLineCount()
|
||||
editor.setPosition({ lineNumber: newLastLine, column: 1 })
|
||||
editor.revealLine(newLastLine)
|
||||
editor.focus()
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
_monacoEditor = null
|
||||
if (validationTimeoutRef.current) {
|
||||
clearTimeout(validationTimeoutRef.current)
|
||||
}
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<CodeModeToolbar
|
||||
validationErrors={markdownValidationErrors}
|
||||
isValidating={isValidating}
|
||||
isValid={isMarkdownValid}
|
||||
onInsertTemplate={handleInsertTemplate}
|
||||
onToggleSyntaxHelp={() => setSyntaxHelpOpen(!syntaxHelpOpen)}
|
||||
syntaxHelpOpen={syntaxHelpOpen}
|
||||
/>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={LANGUAGE_ID}
|
||||
theme={THEME_ID}
|
||||
value={markdownSource ?? ''}
|
||||
onChange={handleEditorChange}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleEditorDidMount}
|
||||
loading={
|
||||
<div className="flex h-full items-center justify-center bg-[#0a0a0a]">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white/20 border-t-white" />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
renderLineHighlight: 'line',
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
automaticLayout: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
quickSuggestions: true,
|
||||
padding: { top: 12, bottom: 12 },
|
||||
accessibilitySupport: 'on',
|
||||
}}
|
||||
/>
|
||||
{/* Syntax help as absolute overlay on right side */}
|
||||
{syntaxHelpOpen && (
|
||||
<div className="absolute right-0 top-0 bottom-0 z-20 w-[280px]">
|
||||
<SyntaxHelpPanel open={syntaxHelpOpen} onClose={() => setSyntaxHelpOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { ChevronDown, AlertCircle, CheckCircle2, Loader2, Plus, HelpCircle } from 'lucide-react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TreeMarkdownValidationError } from '@/types'
|
||||
|
||||
interface CodeModeToolbarProps {
|
||||
validationErrors: TreeMarkdownValidationError[]
|
||||
isValidating: boolean
|
||||
isValid: boolean
|
||||
onInsertTemplate: (template: string) => void
|
||||
onToggleSyntaxHelp: () => void
|
||||
syntaxHelpOpen: boolean
|
||||
}
|
||||
|
||||
const NODE_TEMPLATES = {
|
||||
decision: [
|
||||
'',
|
||||
'---',
|
||||
'id: new_decision',
|
||||
'type: decision',
|
||||
'parent: root',
|
||||
'---',
|
||||
'# What is the question?',
|
||||
'',
|
||||
'> Help text for the engineer',
|
||||
'',
|
||||
'- [A] Option A → @target_id',
|
||||
'- [B] Option B → @target_id',
|
||||
'',
|
||||
].join('\n'),
|
||||
action: [
|
||||
'',
|
||||
'---',
|
||||
'id: new_action',
|
||||
'type: action',
|
||||
'parent: root',
|
||||
'---',
|
||||
'## Action Title',
|
||||
'',
|
||||
'Description of what to do.',
|
||||
'',
|
||||
'```commands',
|
||||
'command here',
|
||||
'```',
|
||||
'',
|
||||
'**Expected:** Expected outcome',
|
||||
'',
|
||||
'→ @next_node_id',
|
||||
'',
|
||||
].join('\n'),
|
||||
solution: [
|
||||
'',
|
||||
'---',
|
||||
'id: new_solution',
|
||||
'type: solution',
|
||||
'parent: root',
|
||||
'---',
|
||||
'## Solution Title',
|
||||
'',
|
||||
'Description of the resolution.',
|
||||
'',
|
||||
'1. Step 1',
|
||||
'2. Step 2',
|
||||
'',
|
||||
].join('\n'),
|
||||
}
|
||||
|
||||
export function CodeModeToolbar({
|
||||
validationErrors,
|
||||
isValidating,
|
||||
isValid,
|
||||
onInsertTemplate,
|
||||
onToggleSyntaxHelp,
|
||||
syntaxHelpOpen,
|
||||
}: CodeModeToolbarProps) {
|
||||
const [insertOpen, setInsertOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const errorCount = validationErrors.filter(e => e.severity === 'error').length
|
||||
const warningCount = validationErrors.filter(e => e.severity === 'warning').length
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setInsertOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] bg-black/50 px-3 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Insert Node dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setInsertOpen(!insertOpen)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-white/10 px-2.5 py-1 text-xs font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Insert Node
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{insertOpen && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 w-44 rounded-lg border border-white/10 bg-[#111] py-1 shadow-xl">
|
||||
<button
|
||||
onClick={() => { onInsertTemplate(NODE_TEMPLATES.decision); setInsertOpen(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-blue-400" />
|
||||
Decision
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onInsertTemplate(NODE_TEMPLATES.action); setInsertOpen(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-amber-400" />
|
||||
Action
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onInsertTemplate(NODE_TEMPLATES.solution); setInsertOpen(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
Solution
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Validation status */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{isValidating ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin text-white/40" />
|
||||
<span className="text-white/40">Validating...</span>
|
||||
</>
|
||||
) : isValid ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-400" />
|
||||
<span className="text-emerald-400/70">Synced</span>
|
||||
{warningCount > 0 && (
|
||||
<span className="text-yellow-400/70">
|
||||
({warningCount} warning{warningCount !== 1 ? 's' : ''})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3 w-3 text-red-400" />
|
||||
<span className="text-red-400/70">
|
||||
{errorCount} error{errorCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{warningCount > 0 && (
|
||||
<span className="text-yellow-400/70">
|
||||
, {warningCount} warning{warningCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Syntax Help toggle */}
|
||||
<button
|
||||
onClick={onToggleSyntaxHelp}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md px-2 py-1 text-xs',
|
||||
syntaxHelpOpen
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
|
||||
)}
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" />
|
||||
Syntax
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SyntaxHelpPanelProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SyntaxHelpPanel({ open, onClose }: SyntaxHelpPanelProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-l border-white/[0.06] bg-[#0a0a0a]">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-2">
|
||||
<span className="text-xs font-medium text-white/60">Syntax Reference</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-white/30 hover:bg-white/10 hover:text-white/60"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 text-[11px] leading-relaxed text-white/50">
|
||||
<Section title="Node Structure">
|
||||
<Code>{`---
|
||||
id: node_id
|
||||
type: decision|action|solution
|
||||
parent: parent_id
|
||||
---`}</Code>
|
||||
</Section>
|
||||
|
||||
<Section title="Decision Node">
|
||||
<Code>{`# Question text here
|
||||
|
||||
> Help text (blockquote)
|
||||
|
||||
- [A] Option label → @target_id
|
||||
- [B] Another option → @target_id`}</Code>
|
||||
</Section>
|
||||
|
||||
<Section title="Action Node">
|
||||
<Code>{`## Action Title
|
||||
|
||||
Description paragraph.
|
||||
|
||||
\`\`\`commands
|
||||
ping 8.8.8.8
|
||||
tracert gateway
|
||||
\`\`\`
|
||||
|
||||
**Expected:** Expected outcome
|
||||
|
||||
→ @next_node_id`}</Code>
|
||||
</Section>
|
||||
|
||||
<Section title="Solution Node">
|
||||
<Code>{`## Solution Title
|
||||
|
||||
Description paragraph.
|
||||
|
||||
1. Resolution step one
|
||||
2. Resolution step two`}</Code>
|
||||
</Section>
|
||||
|
||||
<Section title="Variables">
|
||||
<Row label="User input" code="[USER_INPUT:prompt]" />
|
||||
<Row label="Reference" code="[VAR:name]" />
|
||||
<Row label="Save value" code="[SAVE_AS:name]" />
|
||||
</Section>
|
||||
|
||||
<Section title="Keyboard Shortcuts">
|
||||
<Row label="Toggle mode" code="Ctrl+Shift+M" />
|
||||
<Row label="Insert Decision" code="Ctrl+Shift+D" />
|
||||
<Row label="Insert Action" code="Ctrl+Shift+A" />
|
||||
<Row label="Insert Solution" code="Ctrl+Shift+S" />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/30">{title}</h4>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Code({ children }: { children: string }) {
|
||||
return (
|
||||
<pre className={cn(
|
||||
'mb-2 overflow-x-auto rounded-md border border-white/[0.06] bg-black/50 px-2 py-1.5',
|
||||
'text-[10px] leading-relaxed text-white/50 whitespace-pre'
|
||||
)}>
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, code }: { label: string; code: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-0.5">
|
||||
<span className="text-white/40">{label}</span>
|
||||
<code className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/50">{code}</code>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/tree-editor/code-mode/index.ts
Normal file
3
frontend/src/components/tree-editor/code-mode/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CodeModeEditor, getMonacoEditor } from './CodeModeEditor'
|
||||
export { CodeModeToolbar } from './CodeModeToolbar'
|
||||
export { SyntaxHelpPanel } from './SyntaxHelpPanel'
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { languages, editor, Position, IRange } from 'monaco-editor'
|
||||
|
||||
interface NodeInfo {
|
||||
id: string
|
||||
label: string
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a completion provider for ResolutionFlow markdown.
|
||||
* Provides autocomplete for node references, types, and templates.
|
||||
*/
|
||||
export function createCompletionProvider(
|
||||
getNodes: () => NodeInfo[]
|
||||
): languages.CompletionItemProvider {
|
||||
return {
|
||||
triggerCharacters: ['@', ':', '['],
|
||||
provideCompletionItems(
|
||||
model: editor.ITextModel,
|
||||
position: Position,
|
||||
): languages.ProviderResult<languages.CompletionList> {
|
||||
const lineContent = model.getLineContent(position.lineNumber)
|
||||
const textUntilPosition = lineContent.substring(0, position.column - 1)
|
||||
|
||||
const word = model.getWordUntilPosition(position)
|
||||
const range: IRange = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
}
|
||||
|
||||
const suggestions: languages.CompletionItem[] = []
|
||||
|
||||
// After @ — suggest node IDs
|
||||
if (textUntilPosition.endsWith('@') || textUntilPosition.match(/@\w*$/)) {
|
||||
const atPos = textUntilPosition.lastIndexOf('@')
|
||||
const atRange: IRange = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: atPos + 2, // after the @
|
||||
endColumn: position.column,
|
||||
}
|
||||
|
||||
const nodes = getNodes()
|
||||
for (const node of nodes) {
|
||||
const typeIcon = node.type === 'decision' ? '?' : node.type === 'action' ? '!' : '='
|
||||
suggestions.push({
|
||||
label: `${node.id} — ${node.label}`,
|
||||
kind: 18, // CompletionItemKind.Reference
|
||||
insertText: node.id,
|
||||
detail: `${typeIcon} ${node.type}`,
|
||||
range: atRange,
|
||||
})
|
||||
}
|
||||
return { suggestions }
|
||||
}
|
||||
|
||||
// After "type:" — suggest node types
|
||||
if (textUntilPosition.match(/^type:\s*$/)) {
|
||||
for (const t of ['decision', 'action', 'solution']) {
|
||||
suggestions.push({
|
||||
label: t,
|
||||
kind: 12, // CompletionItemKind.Value
|
||||
insertText: t,
|
||||
range,
|
||||
})
|
||||
}
|
||||
return { suggestions }
|
||||
}
|
||||
|
||||
// After "---\n" at beginning — suggest node template
|
||||
if (lineContent.trim() === '---' && position.lineNumber <= 2) {
|
||||
suggestions.push(
|
||||
{
|
||||
label: 'Decision Node',
|
||||
kind: 14, // CompletionItemKind.Snippet
|
||||
insertText: [
|
||||
'',
|
||||
'id: ${1:node_id}',
|
||||
'type: decision',
|
||||
'---',
|
||||
'# ${2:What is the question?}',
|
||||
'',
|
||||
'> ${3:Help text for the engineer}',
|
||||
'',
|
||||
'- [A] ${4:Option A} → @${5:target_id}',
|
||||
'- [B] ${6:Option B} → @${7:target_id}',
|
||||
].join('\n'),
|
||||
insertTextRules: 4, // InsertTextRule.InsertAsSnippet
|
||||
detail: 'Decision node template',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'Action Node',
|
||||
kind: 14,
|
||||
insertText: [
|
||||
'',
|
||||
'id: ${1:node_id}',
|
||||
'type: action',
|
||||
'parent: ${2:parent_id}',
|
||||
'---',
|
||||
'## ${3:Action Title}',
|
||||
'',
|
||||
'${4:Description of what to do}',
|
||||
'',
|
||||
'```commands',
|
||||
'${5:command here}',
|
||||
'```',
|
||||
'',
|
||||
'**Expected:** ${6:Expected outcome}',
|
||||
'',
|
||||
'→ @${7:next_node_id}',
|
||||
].join('\n'),
|
||||
insertTextRules: 4,
|
||||
detail: 'Action node template',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'Solution Node',
|
||||
kind: 14,
|
||||
insertText: [
|
||||
'',
|
||||
'id: ${1:node_id}',
|
||||
'type: solution',
|
||||
'parent: ${2:parent_id}',
|
||||
'---',
|
||||
'## ${3:Solution Title}',
|
||||
'',
|
||||
'${4:Description}',
|
||||
'',
|
||||
'1. ${5:Step 1}',
|
||||
'2. ${6:Step 2}',
|
||||
].join('\n'),
|
||||
insertTextRules: 4,
|
||||
detail: 'Solution node template',
|
||||
range,
|
||||
},
|
||||
)
|
||||
return { suggestions }
|
||||
}
|
||||
|
||||
// After [VAR: — suggest variable names
|
||||
if (textUntilPosition.match(/\[VAR:$/)) {
|
||||
// Could be extended to track variable names from the document
|
||||
suggestions.push(
|
||||
{ label: 'hostname', kind: 5, insertText: 'hostname', range },
|
||||
{ label: 'username', kind: 5, insertText: 'username', range },
|
||||
{ label: 'ticket_number', kind: 5, insertText: 'ticket_number', range },
|
||||
{ label: 'client_name', kind: 5, insertText: 'client_name', range },
|
||||
)
|
||||
return { suggestions }
|
||||
}
|
||||
|
||||
return { suggestions }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { languages } from 'monaco-editor'
|
||||
|
||||
/**
|
||||
* Monarch tokenizer for ResolutionFlow tree markdown syntax.
|
||||
* Provides syntax highlighting for frontmatter, headings, options, references, and variables.
|
||||
*/
|
||||
export const resolutionFlowLanguage: languages.IMonarchLanguage = {
|
||||
tokenizer: {
|
||||
root: [
|
||||
// Frontmatter delimiter
|
||||
[/^---\s*$/, 'delimiter.frontmatter', '@frontmatter'],
|
||||
|
||||
// Headings
|
||||
[/^##\s+.*$/, 'heading.action'],
|
||||
[/^#\s+.*$/, 'heading.decision'],
|
||||
|
||||
// Blockquote (help text)
|
||||
[/^>\s.*$/, 'string.blockquote'],
|
||||
|
||||
// Option lines: - [X] Label → @target_id
|
||||
[/^-\s*\[[A-Za-z0-9]+\]/, 'keyword.option', '@optionLine'],
|
||||
|
||||
// Next node reference: → @node_id
|
||||
[/^→\s*@\S+/, 'variable.reference'],
|
||||
|
||||
// Expected outcome
|
||||
[/^\*\*Expected:\*\*\s*.*$/, 'keyword.expected'],
|
||||
|
||||
// Command block
|
||||
[/^```commands\s*$/, 'delimiter.commands', '@commandBlock'],
|
||||
[/^```\s*$/, 'delimiter.commands'],
|
||||
|
||||
// Variable tokens
|
||||
[/\[USER_INPUT:[^\]]+\]/, 'variable.input'],
|
||||
[/\[VAR:[^\]]+\]/, 'variable.reference'],
|
||||
[/\[SAVE_AS:[^\]]+\]/, 'variable.save'],
|
||||
|
||||
// Ordered list items
|
||||
[/^\d+\.\s+/, 'keyword.step'],
|
||||
|
||||
// Bold
|
||||
[/\*\*[^*]+\*\*/, 'strong'],
|
||||
|
||||
// Inline code
|
||||
[/`[^`]+`/, 'string.code'],
|
||||
|
||||
// Regular text
|
||||
[/./, 'text'],
|
||||
],
|
||||
|
||||
frontmatter: [
|
||||
[/^---\s*$/, 'delimiter.frontmatter', '@pop'],
|
||||
[/^(id)(:)(.*)$/, ['keyword.frontmatter', 'delimiter', 'string.id']],
|
||||
[/^(type)(:)(.*)$/, ['keyword.frontmatter', 'delimiter', 'type.value']],
|
||||
[/^(parent)(:)(.*)$/, ['keyword.frontmatter', 'delimiter', 'string.parent']],
|
||||
[/./, 'variable.other'],
|
||||
],
|
||||
|
||||
optionLine: [
|
||||
[/→\s*@\S+/, 'variable.reference'],
|
||||
[/$/, '', '@pop'],
|
||||
[/./, 'string.option'],
|
||||
],
|
||||
|
||||
commandBlock: [
|
||||
[/^```\s*$/, 'delimiter.commands', '@pop'],
|
||||
[/./, 'string.command'],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const LANGUAGE_ID = 'resolutionflow'
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
/**
|
||||
* Dark theme for ResolutionFlow markdown syntax.
|
||||
* Matches the monochrome design system with functional color for node types.
|
||||
*/
|
||||
export const resolutionFlowTheme: editor.IStandaloneThemeData = {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
// Frontmatter
|
||||
{ token: 'delimiter.frontmatter', foreground: '6b7280' },
|
||||
{ token: 'keyword.frontmatter', foreground: '9ca3af' },
|
||||
{ token: 'string.id', foreground: 'e5e7eb', fontStyle: 'bold' },
|
||||
{ token: 'type.value', foreground: '60a5fa' },
|
||||
{ token: 'string.parent', foreground: 'a78bfa' },
|
||||
|
||||
// Headings
|
||||
{ token: 'heading.decision', foreground: '60a5fa', fontStyle: 'bold' },
|
||||
{ token: 'heading.action', foreground: 'fbbf24', fontStyle: 'bold' },
|
||||
|
||||
// Options
|
||||
{ token: 'keyword.option', foreground: '60a5fa' },
|
||||
{ token: 'string.option', foreground: 'e5e7eb' },
|
||||
|
||||
// References
|
||||
{ token: 'variable.reference', foreground: 'a78bfa' },
|
||||
|
||||
// Variables
|
||||
{ token: 'variable.input', foreground: 'fb923c', fontStyle: 'bold' },
|
||||
{ token: 'variable.save', foreground: 'fb923c' },
|
||||
|
||||
// Commands
|
||||
{ token: 'delimiter.commands', foreground: '6b7280' },
|
||||
{ token: 'string.command', foreground: '34d399' },
|
||||
|
||||
// Expected outcome
|
||||
{ token: 'keyword.expected', foreground: '34d399' },
|
||||
|
||||
// Ordered list
|
||||
{ token: 'keyword.step', foreground: '9ca3af' },
|
||||
|
||||
// Help text
|
||||
{ token: 'string.blockquote', foreground: '9ca3af', fontStyle: 'italic' },
|
||||
|
||||
// Formatting
|
||||
{ token: 'strong', fontStyle: 'bold' },
|
||||
{ token: 'string.code', foreground: '34d399' },
|
||||
|
||||
// Default
|
||||
{ token: 'text', foreground: 'd1d5db' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#0a0a0a',
|
||||
'editor.foreground': '#d1d5db',
|
||||
'editor.lineHighlightBackground': '#ffffff08',
|
||||
'editor.selectionBackground': '#ffffff20',
|
||||
'editorCursor.foreground': '#ffffff',
|
||||
'editor.inactiveSelectionBackground': '#ffffff10',
|
||||
'editorLineNumber.foreground': '#4b5563',
|
||||
'editorLineNumber.activeForeground': '#9ca3af',
|
||||
'editorGutter.background': '#0a0a0a',
|
||||
'editorWidget.background': '#111111',
|
||||
'editorWidget.border': '#ffffff10',
|
||||
'editorSuggestWidget.background': '#111111',
|
||||
'editorSuggestWidget.border': '#ffffff10',
|
||||
'editorSuggestWidget.selectedBackground': '#ffffff15',
|
||||
},
|
||||
}
|
||||
|
||||
export const THEME_ID = 'resolutionflow-dark'
|
||||
90
frontend/src/lib/treeMarkdownSync.ts
Normal file
90
frontend/src/lib/treeMarkdownSync.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { TreeStructure } from '@/types'
|
||||
|
||||
export interface TreeMetadata {
|
||||
name?: string
|
||||
description?: string
|
||||
category?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight frontend serializer for instant preview when switching Form→Code.
|
||||
* The backend remains authoritative for actual saves.
|
||||
*/
|
||||
export function treeStructureToMarkdownPreview(
|
||||
structure: TreeStructure,
|
||||
metadata?: TreeMetadata,
|
||||
): string {
|
||||
const blocks: string[] = []
|
||||
|
||||
if (metadata) {
|
||||
const fm = ['---']
|
||||
if (metadata.name) fm.push(`name: ${metadata.name}`)
|
||||
if (metadata.description) fm.push(`description: ${metadata.description}`)
|
||||
if (metadata.category) fm.push(`category: ${metadata.category}`)
|
||||
if (metadata.tags?.length) fm.push(`tags: [${metadata.tags.join(', ')}]`)
|
||||
fm.push('---')
|
||||
blocks.push(fm.join('\n'))
|
||||
}
|
||||
|
||||
serializeNode(structure, null, blocks)
|
||||
return blocks.join('\n\n') + '\n'
|
||||
}
|
||||
|
||||
function serializeNode(
|
||||
node: TreeStructure,
|
||||
parentId: string | null,
|
||||
blocks: string[],
|
||||
): void {
|
||||
const fm = ['---', `id: ${node.id}`, `type: ${node.type}`]
|
||||
if (parentId !== null) fm.push(`parent: ${parentId}`)
|
||||
fm.push('---')
|
||||
|
||||
const body: string[] = []
|
||||
|
||||
if (node.type === 'decision') {
|
||||
if (node.question) {
|
||||
body.push(`# ${node.question}`, '')
|
||||
}
|
||||
if (node.help_text) {
|
||||
for (const line of node.help_text.split('\n')) {
|
||||
body.push(`> ${line}`)
|
||||
}
|
||||
body.push('')
|
||||
}
|
||||
if (node.options) {
|
||||
node.options.forEach((opt, i) => {
|
||||
const letter = String.fromCharCode(65 + i)
|
||||
if (opt.next_node_id) {
|
||||
body.push(`- [${letter}] ${opt.label} → @${opt.next_node_id}`)
|
||||
} else {
|
||||
body.push(`- [${letter}] ${opt.label}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (node.type === 'action') {
|
||||
if (node.title) body.push(`## ${node.title}`, '')
|
||||
if (node.description) body.push(node.description, '')
|
||||
if (node.commands?.length) {
|
||||
body.push('```commands')
|
||||
node.commands.forEach(cmd => body.push(cmd))
|
||||
body.push('```', '')
|
||||
}
|
||||
if (node.expected_outcome) body.push(`**Expected:** ${node.expected_outcome}`, '')
|
||||
if (node.next_node_id) body.push(`→ @${node.next_node_id}`)
|
||||
} else if (node.type === 'solution') {
|
||||
if (node.title) body.push(`## ${node.title}`, '')
|
||||
if (node.description) body.push(node.description, '')
|
||||
if (node.resolution_steps?.length) {
|
||||
node.resolution_steps.forEach((step, i) => body.push(`${i + 1}. ${step}`))
|
||||
}
|
||||
}
|
||||
|
||||
blocks.push(fm.join('\n') + '\n' + body.join('\n'))
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
serializeNode(child, node.id, blocks)
|
||||
}
|
||||
}
|
||||
}
|
||||
49
frontend/src/lib/variableResolver.ts
Normal file
49
frontend/src/lib/variableResolver.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Frontend-side variable substitution for display during navigation.
|
||||
*
|
||||
* - [VAR:name] → replaced with variables[name]
|
||||
* - [USER_INPUT:prompt] → replaced with variables[prompt]
|
||||
* - [SAVE_AS:name] → removed from display
|
||||
*/
|
||||
export function resolveVariables(text: string, variables: Record<string, string>): string {
|
||||
// Replace [VAR:name]
|
||||
let result = text.replace(/\[VAR:([^\]]+)\]/g, (_, name) => {
|
||||
const key = name.trim()
|
||||
return variables[key] ?? `[VAR:${key}]`
|
||||
})
|
||||
|
||||
// Replace [USER_INPUT:prompt]
|
||||
result = result.replace(/\[USER_INPUT:([^\]]+)\]/g, (_, prompt) => {
|
||||
const key = prompt.trim()
|
||||
return variables[key] ?? `[USER_INPUT:${key}]`
|
||||
})
|
||||
|
||||
// Remove [SAVE_AS:name]
|
||||
result = result.replace(/\[SAVE_AS:[^\]]+\]/g, '')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all [USER_INPUT:prompt] tokens from text.
|
||||
* Returns array of prompt strings.
|
||||
*/
|
||||
export function extractUserInputPrompts(text: string): string[] {
|
||||
const prompts: string[] = []
|
||||
const re = /\[USER_INPUT:([^\]]+)\]/g
|
||||
let match
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const prompt = match[1].trim()
|
||||
if (!prompts.includes(prompt)) {
|
||||
prompts.push(prompt)
|
||||
}
|
||||
}
|
||||
return prompts
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains any variable tokens.
|
||||
*/
|
||||
export function hasVariableTokens(text: string): boolean {
|
||||
return /\[(USER_INPUT|VAR|SAVE_AS):[^\]]+\]/.test(text)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
||||
import { useStore } from 'zustand'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText } from 'lucide-react'
|
||||
import { treesApi } from '@/api'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList } from 'lucide-react'
|
||||
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
|
||||
import { treesApi, treeMarkdownApi } from '@/api'
|
||||
import type { TreeCreate, TreeUpdate, TreeStatus } from '@/types'
|
||||
import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore'
|
||||
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
|
||||
@@ -24,6 +25,7 @@ export function TreeEditorPage() {
|
||||
isLoading,
|
||||
isSaving,
|
||||
validationErrors,
|
||||
editorMode,
|
||||
initNewTree,
|
||||
loadTree,
|
||||
loadDraft,
|
||||
@@ -34,7 +36,8 @@ export function TreeEditorPage() {
|
||||
markSaved,
|
||||
setLoading,
|
||||
setSaving,
|
||||
selectNode
|
||||
selectNode,
|
||||
setEditorMode,
|
||||
} = useTreeEditorStore()
|
||||
|
||||
// Access undo/redo from temporal store
|
||||
@@ -61,22 +64,50 @@ export function TreeEditorPage() {
|
||||
isDirty && currentLocation.pathname !== nextLocation.pathname
|
||||
)
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (editorMode === 'code') {
|
||||
// In Code Mode, use Monaco's native undo (word-level, like VS Code)
|
||||
const editor = getMonacoEditor()
|
||||
if (editor) {
|
||||
editor.trigger('toolbar', 'undo', null)
|
||||
editor.focus()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (pastStates.length > 0) {
|
||||
undo()
|
||||
toast.info('Undone')
|
||||
}
|
||||
}, [editorMode, pastStates.length, undo])
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
if (editorMode === 'code') {
|
||||
// In Code Mode, use Monaco's native redo (word-level, like VS Code)
|
||||
const editor = getMonacoEditor()
|
||||
if (editor) {
|
||||
editor.trigger('toolbar', 'redo', null)
|
||||
editor.focus()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (futureStates.length > 0) {
|
||||
redo()
|
||||
toast.info('Redone')
|
||||
}
|
||||
}, [editorMode, futureStates.length, redo])
|
||||
|
||||
// Keyboard shortcuts for undo/redo/save
|
||||
useKeyboardShortcuts([
|
||||
{
|
||||
key: 'z',
|
||||
ctrl: true,
|
||||
handler: () => {
|
||||
if (pastStates.length > 0) undo()
|
||||
}
|
||||
handler: handleUndo
|
||||
},
|
||||
{
|
||||
key: 'z',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
handler: () => {
|
||||
if (futureStates.length > 0) redo()
|
||||
}
|
||||
handler: handleRedo
|
||||
},
|
||||
{
|
||||
key: 's',
|
||||
@@ -84,6 +115,14 @@ export function TreeEditorPage() {
|
||||
handler: () => {
|
||||
handleSave()
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'm',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
handler: () => {
|
||||
setEditorMode(editorMode === 'form' ? 'code' : 'form')
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
@@ -165,6 +204,29 @@ export function TreeEditorPage() {
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
// In Code Mode, run fresh validation on current markdown before saving
|
||||
if (editorMode === 'code') {
|
||||
const { markdownSource, setMarkdownValidationResult } = useTreeEditorStore.getState()
|
||||
if (markdownSource) {
|
||||
const result = await treeMarkdownApi.validateMarkdown(markdownSource)
|
||||
setMarkdownValidationResult(result) // applies tree_structure + metadata to store
|
||||
if (!result.valid) {
|
||||
const errorCount = result.errors.filter(e => e.severity === 'error').length
|
||||
toast.error(`Cannot save: ${errorCount} markdown error${errorCount !== 1 ? 's' : ''} — fix them in the editor first`)
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check tree name is set (metadata may come from Code Mode markdown)
|
||||
const currentState = useTreeEditorStore.getState()
|
||||
if (!currentState.name.trim()) {
|
||||
toast.error('Tree name is required. Add a "name:" field in the metadata block or switch to Flow Mode.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const treeData = { ...getTreeForSave(), status: 'draft' as TreeStatus }
|
||||
if (isEditMode) {
|
||||
await treesApi.update(id!, treeData as TreeUpdate)
|
||||
@@ -174,31 +236,67 @@ export function TreeEditorPage() {
|
||||
} else {
|
||||
const newTree = await treesApi.create(treeData as TreeCreate)
|
||||
setTreeStatus('draft')
|
||||
// Mark saved BEFORE navigating to avoid triggering the blocker
|
||||
markSaved()
|
||||
toast.success('Draft created successfully')
|
||||
// Navigate to edit mode with the new ID
|
||||
navigate(`/trees/${newTree.id}/edit`, { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save draft:', err)
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { status?: number; data?: { detail?: string | { message?: string; errors?: string[] } } } }
|
||||
if (axiosErr.response?.status === 422) {
|
||||
const detail = axiosErr.response.data?.detail
|
||||
if (typeof detail === 'object' && detail?.errors) {
|
||||
toast.error(`Validation failed: ${detail.errors.join(', ')}`)
|
||||
} else if (typeof detail === 'string') {
|
||||
toast.error(`Validation failed: ${detail}`)
|
||||
} else {
|
||||
toast.error('Tree has validation errors. Fix them before saving.')
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
toast.error('Failed to save draft. Please try again.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [isEditMode, id, getTreeForSave, markSaved, navigate])
|
||||
}, [isEditMode, id, editorMode, getTreeForSave, markSaved, navigate])
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
// Validate first
|
||||
const errors = validate()
|
||||
const hasErrors = errors.some(e => e.severity === 'error')
|
||||
if (hasErrors) {
|
||||
toast.error('Please fix validation errors before publishing')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
// In Code Mode, run fresh validation on current markdown before publishing
|
||||
if (editorMode === 'code') {
|
||||
const { markdownSource, setMarkdownValidationResult } = useTreeEditorStore.getState()
|
||||
if (markdownSource) {
|
||||
const result = await treeMarkdownApi.validateMarkdown(markdownSource)
|
||||
setMarkdownValidationResult(result)
|
||||
if (!result.valid) {
|
||||
const errorCount = result.errors.filter(e => e.severity === 'error').length
|
||||
toast.error(`Cannot publish: ${errorCount} markdown error${errorCount !== 1 ? 's' : ''} — fix them in the editor first`)
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check tree name is set
|
||||
const currentState = useTreeEditorStore.getState()
|
||||
if (!currentState.name.trim()) {
|
||||
toast.error('Tree name is required. Add a "name:" field in the metadata block or switch to Flow Mode.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate tree structure
|
||||
const errors = validate()
|
||||
const hasErrors = errors.some(e => e.severity === 'error')
|
||||
if (hasErrors) {
|
||||
toast.error('Please fix validation errors before publishing')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const treeData = { ...getTreeForSave(), status: 'published' as TreeStatus }
|
||||
if (isEditMode) {
|
||||
await treesApi.update(id!, treeData as TreeUpdate)
|
||||
@@ -208,10 +306,8 @@ export function TreeEditorPage() {
|
||||
} else {
|
||||
const newTree = await treesApi.create(treeData as TreeCreate)
|
||||
setTreeStatus('published')
|
||||
// Mark saved BEFORE navigating to avoid triggering the blocker
|
||||
markSaved()
|
||||
toast.success('Tree published successfully')
|
||||
// Navigate to edit mode with the new ID
|
||||
navigate(`/trees/${newTree.id}/edit`, { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -220,7 +316,7 @@ export function TreeEditorPage() {
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [isEditMode, id, validate, getTreeForSave, markSaved, navigate])
|
||||
}, [isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate])
|
||||
|
||||
// Keep handleSave for backward compatibility (Ctrl+S shortcut)
|
||||
const handleSave = useCallback(async () => {
|
||||
@@ -371,17 +467,52 @@ export function TreeEditorPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex items-center rounded-md border border-white/[0.06]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode('form')}
|
||||
title="Flow Mode — form-based editing"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-l-md px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
editorMode === 'form'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
|
||||
)}
|
||||
>
|
||||
<LayoutList className="h-3.5 w-3.5" />
|
||||
Flow
|
||||
</button>
|
||||
<div className="h-5 w-px bg-white/[0.06]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode('code')}
|
||||
title="Code Mode — markdown editing (Ctrl+Shift+M)"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-r-md px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
editorMode === 'code'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
|
||||
)}
|
||||
>
|
||||
<Code2 className="h-3.5 w-3.5" />
|
||||
Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-1 h-6 w-px bg-white/[0.06]" />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<div className="flex items-center rounded-md border border-white/[0.06]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => undo()}
|
||||
onClick={handleUndo}
|
||||
disabled={pastStates.length === 0}
|
||||
title={pastStates.length > 0 ? `Undo (Ctrl+Z) - ${pastStates.length} step${pastStates.length !== 1 ? 's' : ''} available` : 'Nothing to undo'}
|
||||
className={cn(
|
||||
'rounded-l-md p-2 transition-colors',
|
||||
pastStates.length > 0
|
||||
? 'text-white hover:bg-white/[0.06]'
|
||||
? 'text-white hover:bg-white/[0.06] active:bg-white/[0.12]'
|
||||
: 'text-white/20 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
@@ -390,13 +521,13 @@ export function TreeEditorPage() {
|
||||
<div className="h-6 w-px bg-white/[0.06]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => redo()}
|
||||
onClick={handleRedo}
|
||||
disabled={futureStates.length === 0}
|
||||
title={futureStates.length > 0 ? `Redo (Ctrl+Shift+Z) - ${futureStates.length} step${futureStates.length !== 1 ? 's' : ''} available` : 'Nothing to redo'}
|
||||
className={cn(
|
||||
'rounded-r-md p-2 transition-colors',
|
||||
futureStates.length > 0
|
||||
? 'text-white hover:bg-white/[0.06]'
|
||||
? 'text-white hover:bg-white/[0.06] active:bg-white/[0.12]'
|
||||
: 'text-white/20 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
import { create } from 'zustand'
|
||||
import { temporal } from 'zundo'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType } from '@/types'
|
||||
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType, TreeMarkdownValidationError, TreeMarkdownValidation } from '@/types'
|
||||
import { treeStructureToMarkdownPreview } from '@/lib/treeMarkdownSync'
|
||||
|
||||
// Throttle helper: captures first call immediately, then throttles subsequent calls
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function throttle<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||
let lastCall = 0
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return ((...args: any[]) => {
|
||||
const now = Date.now()
|
||||
if (now - lastCall >= ms) {
|
||||
lastCall = now
|
||||
fn(...args)
|
||||
} else {
|
||||
// Schedule a trailing call to capture the final state
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
lastCall = Date.now()
|
||||
fn(...args)
|
||||
}, ms - (now - lastCall))
|
||||
}
|
||||
}) as T
|
||||
}
|
||||
|
||||
// Validation error interface
|
||||
export interface ValidationError {
|
||||
@@ -14,6 +38,71 @@ export interface ValidationError {
|
||||
// Draft storage key
|
||||
const DRAFT_STORAGE_KEY = 'tree-editor-draft'
|
||||
|
||||
// Check if a tree is effectively empty (just initialized, no real content)
|
||||
const isEmptyTree = (tree: TreeStructure | null): boolean => {
|
||||
if (!tree) return true
|
||||
if (tree.question && tree.question.trim()) return false
|
||||
if (tree.children && tree.children.length > 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Starter template for new trees in Code Mode — all @refs are valid within the template
|
||||
const CODE_MODE_STARTER_TEMPLATE = `---
|
||||
name: My New Tree
|
||||
description: A troubleshooting decision tree
|
||||
---
|
||||
|
||||
---
|
||||
id: root
|
||||
type: decision
|
||||
---
|
||||
# What type of issue is the user experiencing?
|
||||
|
||||
> Select the category that best matches the reported problem
|
||||
|
||||
- [A] Option A \u2192 @option_a_action
|
||||
- [B] Option B \u2192 @option_b_action
|
||||
|
||||
---
|
||||
id: option_a_action
|
||||
type: action
|
||||
parent: root
|
||||
---
|
||||
## Investigate Option A
|
||||
|
||||
Describe the investigation steps here.
|
||||
|
||||
\`\`\`commands
|
||||
example-command --flag
|
||||
\`\`\`
|
||||
|
||||
**Expected:** Describe expected results here
|
||||
|
||||
\u2192 @resolution
|
||||
|
||||
---
|
||||
id: option_b_action
|
||||
type: action
|
||||
parent: root
|
||||
---
|
||||
## Investigate Option B
|
||||
|
||||
Describe the investigation steps here.
|
||||
|
||||
\u2192 @resolution
|
||||
|
||||
---
|
||||
id: resolution
|
||||
type: solution
|
||||
parent: root
|
||||
---
|
||||
## Resolution
|
||||
|
||||
1. Document findings
|
||||
2. Apply the fix
|
||||
3. Verify the issue is resolved
|
||||
`
|
||||
|
||||
// Helper to generate unique IDs
|
||||
const generateId = () => crypto.randomUUID()
|
||||
|
||||
@@ -114,6 +203,14 @@ interface TreeEditorState {
|
||||
isSaving: boolean
|
||||
validationErrors: ValidationError[]
|
||||
|
||||
// Code Mode state
|
||||
editorMode: 'form' | 'code'
|
||||
markdownSource: string | null
|
||||
markdownValidationErrors: TreeMarkdownValidationError[]
|
||||
isMarkdownValid: boolean
|
||||
isValidating: boolean
|
||||
lastValidTreeFromMarkdown: TreeStructure | null
|
||||
|
||||
// Auto-save state
|
||||
lastSavedAt: Date | null
|
||||
draftSavedAt: Date | null
|
||||
@@ -159,6 +256,13 @@ interface TreeEditorState {
|
||||
markSaved: () => void
|
||||
getTreeForSave: () => TreeCreate | TreeUpdate
|
||||
|
||||
// Actions - Code Mode
|
||||
setEditorMode: (mode: 'form' | 'code') => void
|
||||
setMarkdownSource: (markdown: string) => void
|
||||
setMarkdownValidationResult: (result: TreeMarkdownValidation) => void
|
||||
syncMarkdownToTree: () => void
|
||||
syncTreeToMarkdown: () => void
|
||||
|
||||
// Actions - State
|
||||
setLoading: (loading: boolean) => void
|
||||
setSaving: (saving: boolean) => void
|
||||
@@ -188,6 +292,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
validationErrors: [],
|
||||
editorMode: 'form',
|
||||
markdownSource: null,
|
||||
markdownValidationErrors: [],
|
||||
isMarkdownValid: true,
|
||||
isValidating: false,
|
||||
lastValidTreeFromMarkdown: null,
|
||||
lastSavedAt: null,
|
||||
draftSavedAt: null,
|
||||
hasDraft: false,
|
||||
@@ -216,6 +326,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
state.isLoading = false
|
||||
state.isSaving = false
|
||||
state.validationErrors = []
|
||||
state.editorMode = 'form'
|
||||
state.markdownSource = null
|
||||
state.markdownValidationErrors = []
|
||||
state.isMarkdownValid = true
|
||||
state.isValidating = false
|
||||
state.lastValidTreeFromMarkdown = null
|
||||
state.lastSavedAt = null
|
||||
state.draftSavedAt = null
|
||||
state.hasDraft = hasDraft
|
||||
@@ -292,6 +408,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
state.isLoading = false
|
||||
state.isSaving = false
|
||||
state.validationErrors = []
|
||||
state.editorMode = 'form'
|
||||
state.markdownSource = null
|
||||
state.markdownValidationErrors = []
|
||||
state.isMarkdownValid = true
|
||||
state.isValidating = false
|
||||
state.lastValidTreeFromMarkdown = null
|
||||
state.lastSavedAt = null
|
||||
state.draftSavedAt = null
|
||||
state.hasDraft = false
|
||||
@@ -773,6 +895,96 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
}
|
||||
},
|
||||
|
||||
// Code Mode actions
|
||||
setEditorMode: (mode: 'form' | 'code') => {
|
||||
const current = get()
|
||||
if (mode === current.editorMode) return
|
||||
|
||||
if (mode === 'code') {
|
||||
// Form → Code: generate markdown from tree structure (synchronous)
|
||||
const { treeStructure, name, description, category, tags } = current
|
||||
if (isEmptyTree(treeStructure) && !name.trim()) {
|
||||
// New empty tree: use starter template
|
||||
set((state) => {
|
||||
state.markdownSource = CODE_MODE_STARTER_TEMPLATE
|
||||
state.markdownValidationErrors = []
|
||||
state.isMarkdownValid = false // needs validation
|
||||
state.isValidating = false
|
||||
state.editorMode = mode
|
||||
})
|
||||
} else if (treeStructure) {
|
||||
const metadata = { name, description, category, tags }
|
||||
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
|
||||
set((state) => {
|
||||
state.markdownSource = md
|
||||
state.markdownValidationErrors = []
|
||||
state.isMarkdownValid = true
|
||||
state.isValidating = false
|
||||
state.editorMode = mode
|
||||
})
|
||||
} else {
|
||||
set((state) => { state.editorMode = mode })
|
||||
}
|
||||
} else {
|
||||
// Code → Form: apply last valid parse to tree structure
|
||||
get().syncMarkdownToTree()
|
||||
set((state) => { state.editorMode = mode })
|
||||
}
|
||||
},
|
||||
|
||||
setMarkdownSource: (markdown: string) => {
|
||||
set((state) => {
|
||||
state.markdownSource = markdown
|
||||
state.isDirty = true
|
||||
state.isValidating = true
|
||||
})
|
||||
},
|
||||
|
||||
setMarkdownValidationResult: (result: TreeMarkdownValidation) => {
|
||||
set((state) => {
|
||||
state.markdownValidationErrors = result.errors
|
||||
state.isMarkdownValid = result.valid
|
||||
state.isValidating = false
|
||||
if (result.valid && result.tree_structure) {
|
||||
state.lastValidTreeFromMarkdown = result.tree_structure as TreeStructure
|
||||
// Live sync: apply valid parsed tree to treeStructure immediately
|
||||
state.treeStructure = result.tree_structure as TreeStructure
|
||||
}
|
||||
// Apply metadata from parsed markdown (bidirectional sync)
|
||||
if (result.metadata) {
|
||||
if (result.metadata.name !== undefined) state.name = result.metadata.name
|
||||
if (result.metadata.description !== undefined) state.description = result.metadata.description
|
||||
if (result.metadata.category !== undefined) state.category = result.metadata.category
|
||||
if (result.metadata.tags !== undefined) state.tags = result.metadata.tags
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
syncMarkdownToTree: () => {
|
||||
const { lastValidTreeFromMarkdown, isMarkdownValid } = get()
|
||||
if (isMarkdownValid && lastValidTreeFromMarkdown) {
|
||||
set((state) => {
|
||||
state.treeStructure = lastValidTreeFromMarkdown
|
||||
state.markdownSource = null
|
||||
state.isDirty = true
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
syncTreeToMarkdown: () => {
|
||||
const { treeStructure, name, description, category, tags } = get()
|
||||
if (treeStructure) {
|
||||
const metadata = { name, description, category, tags }
|
||||
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
|
||||
set((state) => {
|
||||
state.markdownSource = md
|
||||
state.markdownValidationErrors = []
|
||||
state.isMarkdownValid = true
|
||||
state.isValidating = false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
setLoading: (loading: boolean) => {
|
||||
set((state) => { state.isLoading = loading })
|
||||
},
|
||||
@@ -839,7 +1051,15 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
tags: state.tags,
|
||||
isPublic: state.isPublic,
|
||||
treeStructure: state.treeStructure
|
||||
})
|
||||
}),
|
||||
// Skip no-op entries where partialized fields haven't changed
|
||||
equality: (pastState, currentState) => shallow(pastState, currentState),
|
||||
// Throttle history captures: collapse rapid changes (typing, validation) into ~one entry per 3s
|
||||
// This makes Flow Mode undo revert meaningful chunks (whole field edits) instead of single characters
|
||||
handleSet: (handleSet) =>
|
||||
throttle<typeof handleSet>((state) => {
|
||||
handleSet(state)
|
||||
}, 3000),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { persist } from 'zustand/middleware'
|
||||
type ExportFormat = 'markdown' | 'text' | 'html' | 'psa'
|
||||
type TreeLibraryView = 'grid' | 'list' | 'table'
|
||||
type TreeSortBy = 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
type EditorMode = 'form' | 'code'
|
||||
|
||||
interface UserPreferencesState {
|
||||
defaultExportFormat: ExportFormat
|
||||
@@ -12,6 +13,8 @@ interface UserPreferencesState {
|
||||
setTreeLibraryView: (view: TreeLibraryView) => void
|
||||
treeLibrarySortBy: TreeSortBy
|
||||
setTreeLibrarySortBy: (sortBy: TreeSortBy) => void
|
||||
preferredEditorMode: EditorMode
|
||||
setPreferredEditorMode: (mode: EditorMode) => void
|
||||
}
|
||||
|
||||
export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
@@ -23,6 +26,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
setTreeLibraryView: (view) => set({ treeLibraryView: view }),
|
||||
treeLibrarySortBy: 'usage_count',
|
||||
setTreeLibrarySortBy: (sortBy) => set({ treeLibrarySortBy: sortBy }),
|
||||
preferredEditorMode: 'form',
|
||||
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: 'user-preferences-storage',
|
||||
|
||||
@@ -185,3 +185,25 @@ export interface TreeValidationResponse {
|
||||
can_publish: boolean
|
||||
errors: ValidationError[]
|
||||
}
|
||||
|
||||
// Tree markdown types
|
||||
export interface TreeMarkdownValidationError {
|
||||
line: number
|
||||
column: number
|
||||
message: string
|
||||
severity: 'error' | 'warning'
|
||||
}
|
||||
|
||||
export interface TreeMarkdownMetadata {
|
||||
name?: string
|
||||
description?: string
|
||||
category?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface TreeMarkdownValidation {
|
||||
valid: boolean
|
||||
errors: TreeMarkdownValidationError[]
|
||||
tree_structure: TreeStructure | null
|
||||
metadata?: TreeMarkdownMetadata | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user