Initial scaffold for artifact-editor project

This commit is contained in:
rob 2025-12-18 22:06:34 -04:00
commit 6ccdd84835
7 changed files with 653 additions and 0 deletions

135
README.md Normal file
View File

@ -0,0 +1,135 @@
# Artifact Editor
**AI-enhanced editor for creating diagrams, sketches, 3D models, and other artifacts from code.**
A standalone editor that can be launched from any application to create visual artifacts. Designed to integrate with discussion tools, documentation systems, and IDEs.
## Vision
Enable users to create rich visual artifacts through multiple input methods:
- **Code editing** - Write Mermaid, PlantUML, OpenSCAD, SVG directly
- **Graphical interface** - Drag-and-drop, visual editing
- **Voice input** - Describe what you want, AI generates the code
- **AI assistance** - "Add a database to this diagram", "Make the cube hollow"
## Supported Artifact Types
| Type | Description | Output Formats |
|------|-------------|----------------|
| `mermaid` | Flowcharts, sequence, ER, state diagrams | SVG, PNG, PDF |
| `plantuml` | UML diagrams (class, sequence, etc.) | SVG, PNG |
| `openscad` | 3D parametric CAD models | STL, PNG, SVG |
| `solidpython` | Python-based 3D CAD (via SolidPython) | STL, PNG, SCAD |
| `svg` | Raw SVG vector graphics | SVG |
| `asciiart` | ASCII/Unicode box drawing | TXT, PNG |
| `excalidraw` | Hand-drawn style diagrams | SVG, PNG |
| `code` | Syntax-highlighted code snippets | SVG, PNG, HTML |
## Installation
```bash
pip install artifact-editor
# Optional renderers
npm install -g @mermaid-js/mermaid-cli # For Mermaid
sudo apt install openscad # For 3D CAD
pip install solidpython2 # For SolidPython
```
## Usage
### As a Standalone Editor
```bash
# Launch editor for a new Mermaid diagram
artifact-editor --type mermaid --output diagram.svg
# Edit an existing artifact
artifact-editor --type openscad --output model.stl --initial-file model.scad
# Headless AI generation
artifact-editor --type mermaid --output flow.svg --headless \
--prompt "Create a flowchart for user authentication"
```
### Integration with Other Applications
The editor follows a simple contract for integration:
```bash
# Launch
artifact-editor --type TYPE --output /path/to/output.svg [--project NAME]
# Exit behavior:
# - Exit code 0: Saved successfully
# stdout: "ARTIFACT_SAVED:/path/to/output.svg"
#
# - Exit code 1: Cancelled by user
# (no output)
#
# - Exit code 2: Error
# stderr: error message
```
Example integration (Python):
```python
import subprocess
result = subprocess.run([
"artifact-editor",
"--type", "mermaid",
"--output", "/tmp/diagram.svg",
"--project", "my-project"
], capture_output=True, text=True)
if result.returncode == 0:
# Parse output to get file path
for line in result.stdout.split('\n'):
if line.startswith("ARTIFACT_SAVED:"):
artifact_path = line.split(":", 1)[1]
print(f"Created: {artifact_path}")
```
## Architecture
```
artifact-editor/
├── src/artifact_editor/
│ ├── cli.py # CLI entry point
│ ├── editor.py # TUI editor (urwid-based)
│ ├── ai_assist.py # AI generation/modification
│ ├── renderers/ # Convert source to visual output
│ │ ├── mermaid.py
│ │ ├── plantuml.py
│ │ ├── openscad.py
│ │ ├── svg.py
│ │ └── ...
│ └── ui/ # UI components
│ ├── code_editor.py
│ ├── preview.py
│ └── ai_panel.py
```
## Roadmap
- [ ] Basic TUI with code editor and preview
- [ ] Mermaid renderer
- [ ] OpenSCAD/SolidPython renderer
- [ ] AI assistance integration (via SmartTools)
- [ ] Voice input (via SmartTools dictate)
- [ ] PlantUML renderer
- [ ] SVG direct editing
- [ ] Excalidraw-style sketching
- [ ] Plugin system for custom renderers
## Integration Goals
This editor is designed to integrate with:
- **orchestrated-discussions** - Add artifacts to discussion comments
- **Documentation systems** - Embed diagrams in docs
- **IDEs** - Quick diagram creation from code
- **Chat applications** - Share visual explanations
## License
MIT

65
pyproject.toml Normal file
View File

@ -0,0 +1,65 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "artifact-editor"
version = "0.1.0"
description = "AI-enhanced editor for creating diagrams, sketches, 3D models, and other artifacts from code"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
{name = "Rob"}
]
keywords = ["diagrams", "uml", "mermaid", "openscad", "3d", "artifacts", "editor", "ai"]
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Multimedia :: Graphics",
"Topic :: Software Development :: Documentation",
]
dependencies = [
"PyYAML>=6.0",
]
[project.optional-dependencies]
tui = [
"urwid>=2.1.0",
]
mermaid = [
# mermaid-py or use CLI
]
openscad = [
"solidpython2>=2.0.0",
]
all = [
"urwid>=2.1.0",
"solidpython2>=2.0.0",
]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"urwid>=2.1.0",
]
[project.scripts]
artifact-editor = "artifact_editor.cli:main"
[project.urls]
Homepage = "https://gitea.brrd.tech/rob/artifact-editor"
Repository = "https://gitea.brrd.tech/rob/artifact-editor.git"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

View File

@ -0,0 +1,3 @@
"""Artifact Editor - AI-enhanced editor for diagrams, sketches, and 3D models."""
__version__ = "0.1.0"

132
src/artifact_editor/cli.py Normal file
View File

@ -0,0 +1,132 @@
"""CLI entry point for Artifact Editor."""
import argparse
import sys
from pathlib import Path
from . import __version__
# Supported artifact types
ARTIFACT_TYPES = [
"mermaid", # Flowcharts, sequence diagrams, ER diagrams
"plantuml", # UML diagrams
"svg", # Raw SVG graphics
"openscad", # 3D CAD (OpenSCAD/SolidPython)
"asciiart", # ASCII/box drawing diagrams
"code", # Syntax-highlighted code snippets
"excalidraw", # Hand-drawn style diagrams (JSON)
]
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
prog="artifact-editor",
description="AI-enhanced editor for creating diagrams, sketches, and 3D models"
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
parser.add_argument(
"-t", "--type",
choices=ARTIFACT_TYPES,
default="mermaid",
help=f"Artifact type: {', '.join(ARTIFACT_TYPES)}"
)
parser.add_argument(
"-o", "--output",
required=True,
help="Output file path for the artifact"
)
parser.add_argument(
"-p", "--project",
default="",
help="Project name/context for AI assistance"
)
parser.add_argument(
"-i", "--initial",
default="",
help="Initial content to pre-populate editor"
)
parser.add_argument(
"--initial-file",
help="File containing initial content"
)
parser.add_argument(
"--headless",
action="store_true",
help="Run without UI (for AI-only generation)"
)
parser.add_argument(
"--prompt",
help="AI prompt for headless generation"
)
args = parser.parse_args()
# Load initial content from file if specified
initial_content = args.initial
if args.initial_file:
initial_path = Path(args.initial_file)
if initial_path.exists():
initial_content = initial_path.read_text()
else:
print(f"Error: Initial file not found: {args.initial_file}", file=sys.stderr)
return 1
output_path = Path(args.output).expanduser().resolve()
if args.headless:
# Headless mode: generate via AI without UI
if not args.prompt:
print("Error: --prompt required for headless mode", file=sys.stderr)
return 1
return run_headless(
artifact_type=args.type,
output_path=output_path,
prompt=args.prompt,
project=args.project,
initial=initial_content
)
else:
# Interactive mode: launch editor UI
return run_editor(
artifact_type=args.type,
output_path=output_path,
project=args.project,
initial=initial_content
)
def run_editor(artifact_type: str, output_path: Path, project: str, initial: str) -> int:
"""Launch the interactive editor UI."""
# TODO: Implement TUI editor
print(f"[TODO] Launch editor for {artifact_type}")
print(f" Output: {output_path}")
print(f" Project: {project or '(none)'}")
print(f" Initial content: {len(initial)} chars")
# Placeholder: just write initial content or template
if initial:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(initial)
print(f"ARTIFACT_SAVED:{output_path}")
return 0
print("Error: Editor UI not yet implemented", file=sys.stderr)
return 1
def run_headless(artifact_type: str, output_path: Path, prompt: str, project: str, initial: str) -> int:
"""Generate artifact via AI without UI."""
# TODO: Implement AI generation
print(f"[TODO] Headless generation for {artifact_type}")
print(f" Prompt: {prompt}")
print(f" Output: {output_path}")
print("Error: Headless generation not yet implemented", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,65 @@
"""Artifact renderers - convert source code to visual output."""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional
class Renderer(ABC):
"""Base class for artifact renderers."""
name: str = "base"
extensions: list[str] = []
@abstractmethod
def render(self, source: str, output_path: Path) -> bool:
"""
Render source code to output file.
Args:
source: The source code/markup
output_path: Where to save the rendered output
Returns:
True if successful, False otherwise
"""
pass
@abstractmethod
def validate(self, source: str) -> tuple[bool, Optional[str]]:
"""
Validate source code syntax.
Args:
source: The source code/markup to validate
Returns:
(is_valid, error_message) - error_message is None if valid
"""
pass
def get_template(self) -> str:
"""Return a starter template for this artifact type."""
return ""
# Registry of available renderers
_renderers: dict[str, type[Renderer]] = {}
def register_renderer(renderer_class: type[Renderer]) -> type[Renderer]:
"""Decorator to register a renderer."""
_renderers[renderer_class.name] = renderer_class
return renderer_class
def get_renderer(name: str) -> Optional[Renderer]:
"""Get a renderer instance by name."""
if name in _renderers:
return _renderers[name]()
return None
def list_renderers() -> list[str]:
"""List available renderer names."""
return list(_renderers.keys())

View File

@ -0,0 +1,76 @@
"""Mermaid diagram renderer."""
import subprocess
import shutil
from pathlib import Path
from typing import Optional
from . import Renderer, register_renderer
@register_renderer
class MermaidRenderer(Renderer):
"""Render Mermaid diagrams to SVG/PNG."""
name = "mermaid"
extensions = [".svg", ".png", ".pdf"]
def __init__(self):
self.cli_path = shutil.which("mmdc") # mermaid-cli
def render(self, source: str, output_path: Path) -> bool:
"""Render Mermaid source to output file."""
if not self.cli_path:
print("Error: mermaid-cli (mmdc) not found. Install with: npm install -g @mermaid-js/mermaid-cli")
return False
# Write source to temp file
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.mmd', delete=False) as f:
f.write(source)
temp_input = f.name
try:
result = subprocess.run(
[self.cli_path, "-i", temp_input, "-o", str(output_path)],
capture_output=True,
text=True
)
if result.returncode != 0:
print(f"Mermaid error: {result.stderr}")
return False
return output_path.exists()
finally:
Path(temp_input).unlink(missing_ok=True)
def validate(self, source: str) -> tuple[bool, Optional[str]]:
"""Validate Mermaid syntax."""
# Basic validation - check for diagram type declaration
source = source.strip()
valid_starts = [
"graph ", "graph\n",
"flowchart ", "flowchart\n",
"sequenceDiagram", "classDiagram", "stateDiagram",
"erDiagram", "gantt", "pie", "journey",
"gitGraph", "mindmap", "timeline",
]
for start in valid_starts:
if source.startswith(start):
return True, None
return False, "Unknown diagram type. Start with: graph, flowchart, sequenceDiagram, classDiagram, etc."
def get_template(self) -> str:
"""Return a starter Mermaid template."""
return """graph TD
A[Start] --> B{Decision}
B -->|Yes| C[Action 1]
B -->|No| D[Action 2]
C --> E[End]
D --> E
"""

View File

@ -0,0 +1,177 @@
"""OpenSCAD/SolidPython 3D CAD renderer."""
import subprocess
import shutil
from pathlib import Path
from typing import Optional
from . import Renderer, register_renderer
@register_renderer
class OpenSCADRenderer(Renderer):
"""Render OpenSCAD or SolidPython to 3D models."""
name = "openscad"
extensions = [".stl", ".png", ".scad", ".svg"]
def __init__(self):
self.openscad_path = shutil.which("openscad")
def render(self, source: str, output_path: Path) -> bool:
"""Render OpenSCAD source to output file."""
if not self.openscad_path:
print("Error: openscad not found. Install with: sudo apt install openscad")
return False
# Detect if source is SolidPython (Python) or native OpenSCAD
is_solidpython = "from solid" in source or "import solid" in source
if is_solidpython:
# Execute Python to generate OpenSCAD
scad_source = self._run_solidpython(source)
if scad_source is None:
return False
else:
scad_source = source
# Write SCAD to temp file
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.scad', delete=False) as f:
f.write(scad_source)
temp_input = f.name
try:
suffix = output_path.suffix.lower()
if suffix == ".scad":
# Just save the SCAD source
output_path.write_text(scad_source)
return True
elif suffix == ".stl":
result = subprocess.run(
[self.openscad_path, "-o", str(output_path), temp_input],
capture_output=True,
text=True
)
elif suffix == ".png":
result = subprocess.run(
[self.openscad_path, "-o", str(output_path),
"--autocenter", "--viewall", temp_input],
capture_output=True,
text=True
)
elif suffix == ".svg":
result = subprocess.run(
[self.openscad_path, "-o", str(output_path), temp_input],
capture_output=True,
text=True
)
else:
print(f"Unsupported output format: {suffix}")
return False
if result.returncode != 0:
print(f"OpenSCAD error: {result.stderr}")
return False
return output_path.exists()
finally:
Path(temp_input).unlink(missing_ok=True)
def _run_solidpython(self, source: str) -> Optional[str]:
"""Execute SolidPython code and return OpenSCAD output."""
import tempfile
import sys
# Create temp Python file
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
# Add code to output the SCAD
wrapper = f'''
{source}
# Auto-added: output the result
if __name__ == "__main__":
import sys
# Find the main object (usually last assigned variable or 'result')
_locals = dict(locals())
_obj = None
for _name in reversed(list(_locals.keys())):
_val = _locals[_name]
if hasattr(_val, 'render') or hasattr(_val, '_render'):
_obj = _val
break
if _obj is not None:
from solid2 import scad_render
print(scad_render(_obj))
else:
print("// No SolidPython object found", file=sys.stderr)
'''
f.write(wrapper)
temp_py = f.name
try:
result = subprocess.run(
[sys.executable, temp_py],
capture_output=True,
text=True
)
if result.returncode != 0:
print(f"SolidPython error: {result.stderr}")
return None
return result.stdout
finally:
Path(temp_py).unlink(missing_ok=True)
def validate(self, source: str) -> tuple[bool, Optional[str]]:
"""Validate OpenSCAD/SolidPython syntax."""
# Basic validation
is_solidpython = "from solid" in source or "import solid" in source
if is_solidpython:
# Try to parse as Python
import ast
try:
ast.parse(source)
return True, None
except SyntaxError as e:
return False, f"Python syntax error: {e}"
else:
# Basic OpenSCAD checks
if not any(kw in source for kw in ["cube", "sphere", "cylinder", "polyhedron",
"circle", "square", "polygon",
"linear_extrude", "rotate_extrude",
"union", "difference", "intersection",
"module", "function"]):
return False, "No OpenSCAD primitives or operations found"
return True, None
def get_template(self) -> str:
"""Return a starter OpenSCAD template."""
return """// Simple OpenSCAD example
$fn = 32; // Smoothness
difference() {
// Outer cube
cube([20, 20, 20], center=true);
// Subtract a sphere
sphere(r=12);
}
"""
def get_solidpython_template(self) -> str:
"""Return a starter SolidPython template."""
return """from solid2 import *
# Set smoothness
set_global_fn(32)
# Create a cube with a spherical hole
result = cube([20, 20, 20], center=True) - sphere(r=12)
"""