routes/grid_gen.py
Rama main · réplica de lectura del repositorio original.
"""Grid Creator order tracking.
The Grid Creator page records an order here, then triggers this tenant's voice agent
(via a canvas 'speak' shortcut) to mesh-send the job to the Mac creative node under the
agent's own identity — so the Mac knows the client/brand. The Mac generates via ChatGPT
(no per-image cost), writes the finished images into this tenant's uploads/, and drops a
status marker the page polls here.
This route only OWNS the order record + status read-back. It deliberately does NOT touch the
mesh queue (OVU has no mesh mount) — the voice agent is the bridge, per the confirmed contract
in docs/jambot/grid-creator-spec.md.
Order/status files live UNDER the uploads/ dir — the one runtime subdir that is bind-mounted
to the host (`/mnt/clients/<t>/openvoiceui/uploads/`) AND is world-writable (drwxrwxrwx), so
BOTH this route (container uid 1001) and the Mac creative node (host uid 1000) can write to it.
A top-level runtime/grid-orders/ would live only inside the container fs — the Mac's host-side
marker would be invisible to the page. Do NOT move this out from under uploads/ without adding a
corresponding compose bind-mount to every tenant.
uploads/grid-orders/<request_id>.json # created by this route (status=queued)
uploads/grid-orders/<request_id>.status.json # written by the Mac on progress/completion
Host path the Mac writes to: /mnt/clients/<client_id>/openvoiceui/uploads/grid-orders/
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from flask import Blueprint, request, jsonify
from services.paths import RUNTIME_DIR, UPLOADS_DIR
grid_gen_bp = Blueprint('grid_gen', __name__)
# Under uploads/ (bind-mounted + world-writable) so the Mac's host-side markers are visible here.
ORDERS_DIR = RUNTIME_DIR / 'uploads' / 'grid-orders'
_VALID_TYPES = {'post', 'logo', 'character', 'mascot'}
_IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.avif', '.heic', '.heif'}
_MAX_REFS = 6
def _sanitize_refs(raw) -> list:
"""Keep only real image files that actually live in this tenant's uploads/ dir.
Refs are basenames the user picked/uploaded (e.g. 'logo__a1b2c3d4.png'). We reject anything
with a path separator or that doesn't resolve to an existing image directly under uploads/,
so a ref can never point the Mac at a file outside the tenant's own uploads.
"""
out = []
for r in (raw or [])[:24]:
name = (str(r) or '').strip().lstrip('/')
if not name or '/' in name or '\\' in name or '..' in name:
continue
p = UPLOADS_DIR / name
if p.suffix.lower() in _IMAGE_EXTS and p.is_file():
if name not in out:
out.append(name)
if len(out) >= _MAX_REFS:
break
return out
def _orders_dir() -> Path:
ORDERS_DIR.mkdir(parents=True, exist_ok=True)
# This route usually creates the dir first (order placed before the Mac ever sees it); make
# it world-writable so the Mac (different uid) can drop status markers into the same dir.
try:
os.chmod(ORDERS_DIR, 0o777)
except OSError:
pass
return ORDERS_DIR
@grid_gen_bp.route('/api/grid-gen/orders', methods=['POST'])
def create_order():
d = request.get_json(silent=True) or {}
rid = (d.get('request_id') or '').strip()
if not rid or '/' in rid or '..' in rid:
return jsonify({'error': 'valid request_id required'}), 400
if (d.get('grid_type') or '') not in _VALID_TYPES:
return jsonify({'error': 'invalid grid_type'}), 400
order = {
'request_id': rid,
'grid_type': d.get('grid_type'),
'brief': (d.get('brief') or '')[:2000],
'count': max(1, min(24, int(d.get('count') or 9))),
'aspect': d.get('aspect') or '1:1',
'layout': 'composite' if d.get('layout') != 'separate' else 'separate',
# Reference images the user uploaded/picked from uploads/. The Mac reads these from this
# order record (authoritative) and passes them to the generator as --img inputs — the
# agent path never has to relay filenames. Basenames only; validated to exist in uploads/.
'refs': _sanitize_refs(d.get('refs')),
'status': 'queued',
'created_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
}
(_orders_dir() / f'{rid}.json').write_text(json.dumps(order))
return jsonify({'ok': True, 'request_id': rid, 'refs': order['refs']})
@grid_gen_bp.route('/api/grid-gen/uploads', methods=['GET'])
def list_uploads():
"""Image files in this tenant's uploads/ dir, for the reference-image picker.
Flat listing (excludes subdirs like grid-orders/, albums/) newest-first, images only.
"""
out = []
try:
entries = [p for p in UPLOADS_DIR.iterdir()
if p.is_file() and not p.name.startswith('.') and p.suffix.lower() in _IMAGE_EXTS]
except OSError:
entries = []
for p in sorted(entries, key=lambda p: p.stat().st_mtime, reverse=True)[:300]:
out.append({'name': p.name, 'url': f'/uploads/{p.name}', 'mtime': int(p.stat().st_mtime)})
return jsonify(out)
@grid_gen_bp.route('/api/grid-gen/orders', methods=['GET'])
def list_orders():
out = []
d = _orders_dir()
for f in sorted(d.glob('*.json'), key=lambda p: p.stat().st_mtime, reverse=True):
if f.name.endswith('.status.json'):
continue
try:
order = json.loads(f.read_text())
except (OSError, json.JSONDecodeError):
continue
# Merge the Mac's status marker if present (it wins on status/files/error).
marker = d / f"{order.get('request_id')}.status.json"
if marker.exists():
try:
st = json.loads(marker.read_text())
for k in ('status', 'files', 'error', 'done', 'updated_at'):
if k in st:
order[k] = st[k]
except (OSError, json.JSONDecodeError):
pass
out.append(order)
return jsonify(out[:50])