feat: add project file system with CRUD, upload, inline editing, and video attachment support

- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
This commit is contained in:
2026-06-08 20:51:09 +00:00
parent e0535bb7c5
commit 97eb58fc19
110 changed files with 5534 additions and 603 deletions
@@ -313,6 +313,95 @@ def _hourly(rows: list[dict], first_hour: dict) -> list[dict]:
return sorted(buckets.values(), key=lambda b: b["hour"], reverse=True)
def empty_user_usage(owner_id: str, hours: int = 24) -> dict:
return {
"owner_id": owner_id,
"window_hours": hours,
"generated_at": _iso(_now()),
"requests": 0,
"success": 0,
"failed": 0,
"success_pct": 0.0,
"error_pct": 0.0,
"tokens": {"prompt": 0, "completion": 0, "total": 0},
"cost": {"window_usd": 0.0, "per_hour_usd": 0.0, "per_request_usd": 0.0, "projected_30d_usd": 0.0},
"latency": {"avg_ms": 0.0, "avg_tps": 0.0},
"first_used": None,
"last_used": None,
"by_model": [],
"by_backend": [],
"hourly": [],
"notes": {"projection": "30-day projection extrapolates the full 24h spend (24h cost x 30)"},
}
def _user_hourly(rows: list[dict]) -> list[dict]:
buckets: dict = {}
for r in rows:
hour = r["created_at"][:13]
bucket = buckets.setdefault(hour, {"hour": hour, "requests": 0, "cost_usd": 0.0, "total_tokens": 0})
bucket["requests"] += 1
bucket["cost_usd"] += float(r.get("cost_usd") or 0)
bucket["total_tokens"] += int(r.get("total_tokens") or 0)
out = sorted(buckets.values(), key=lambda b: b["hour"])
for bucket in out:
bucket["cost_usd"] = round(bucket["cost_usd"], 6)
return out
def build_user_usage(owner_id: str, hours: int = 24, pricing: Optional[Pricing] = None) -> dict:
hours = max(1, min(hours, MAX_WINDOW_HOURS))
if not owner_id or GATEWAY_LEDGER not in db.tables:
return empty_user_usage(owner_id, hours)
now = _now()
cutoff = _iso(now - timedelta(hours=hours))
rows = list(db.query(
f"SELECT * FROM {GATEWAY_LEDGER} WHERE owner_id = :oid AND created_at >= :cutoff ORDER BY created_at",
oid=owner_id, cutoff=cutoff,
))
if not rows:
return empty_user_usage(owner_id, hours)
requests = len(rows)
success = sum(int(r.get("success") or 0) for r in rows)
failed = requests - success
total_cost = sum(float(r.get("cost_usd") or 0) for r in rows)
prompt_total = sum(int(r.get("prompt_tokens") or 0) for r in rows)
completion_total = sum(int(r.get("completion_tokens") or 0) for r in rows)
total_tokens = sum(int(r.get("total_tokens") or 0) for r in rows)
latencies = _positive(rows, "upstream_latency_ms")
avg_latency = round(sum(latencies) / len(latencies), 1) if latencies else 0.0
tps = _positive(rows, "tokens_per_second")
avg_tps = round(sum(tps) / len(tps), 2) if tps else 0.0
cost_per_hour = total_cost / hours
cost_per_request = total_cost / requests if requests else 0.0
return {
"owner_id": owner_id,
"window_hours": hours,
"generated_at": _iso(now),
"requests": requests,
"success": success,
"failed": failed,
"success_pct": round(success / requests * 100, 1) if requests else 0.0,
"error_pct": round(failed / requests * 100, 1) if requests else 0.0,
"tokens": {"prompt": prompt_total, "completion": completion_total, "total": total_tokens},
"cost": {
"window_usd": round(total_cost, 6),
"per_hour_usd": round(cost_per_hour, 6),
"per_request_usd": round(cost_per_request, 6),
"projected_30d_usd": round(cost_per_hour * 24 * 30, 2),
},
"latency": {"avg_ms": avg_latency, "avg_tps": avg_tps},
"first_used": rows[0]["created_at"],
"last_used": rows[-1]["created_at"],
"by_model": _top_group(rows, lambda r: r.get("model") or "unknown", 0),
"by_backend": _top_group(rows, lambda r: r.get("backend") or "unknown", 0),
"hourly": _user_hourly(rows),
"notes": {"projection": "30-day projection extrapolates the full 24h spend (24h cost x 30)"},
}
def summary_metrics() -> dict:
zero = {"requests": 0, "success_pct": 0.0, "error_pct": 0.0, "cost_hour": 0.0,
"cost_24h": 0.0, "tokens_24h": 0, "avg_latency_ms": 0.0, "avg_tps": 0.0,
+7 -4
View File
@@ -1,6 +1,7 @@
UPSTREAM_URL_DEFAULT = "https://api.deepseek.com/chat/completions"
MODEL_DEFAULT = "deepseek-chat"
TIMEOUT_DEFAULT = 180
MODEL_DEFAULT = "deepseek-v4-flash"
TIMEOUT_DEFAULT = 300
TIMEOUT_MIN = 300
INSTANCES_DEFAULT = 4
VISION_URL_DEFAULT = "https://openrouter.ai/api/v1/chat/completions"
@@ -27,7 +28,9 @@ CIRCUIT_THRESHOLD_DEFAULT = 5
CIRCUIT_COOLDOWN_SECONDS_DEFAULT = 30
MODEL_CONTEXT_MAP_DEFAULT = {
"deepseek-chat": 65536,
"deepseek-reasoner": 65536,
"deepseek-v4-flash": 1_048_576,
"deepseek-v4-pro": 1_048_576,
"deepseek-chat": 1_048_576,
"deepseek-reasoner": 1_048_576,
"google/gemma-3-12b-it": 8192,
}
@@ -46,8 +46,8 @@ class GatewayService(BaseService):
ConfigField("gateway_api_key", "Upstream API key", type="str", default="",
help="The key currently in use; auto-migrated from DEEPSEEK_API_KEY or OPENROUTER_API_KEY on boot. Editable.",
group="Upstream"),
ConfigField("gateway_timeout", "Upstream timeout (seconds)", type="int", default=config.TIMEOUT_DEFAULT, minimum=1,
help="Per-request upstream timeout.", group="Upstream"),
ConfigField("gateway_timeout", "Upstream timeout (seconds)", type="int", default=config.TIMEOUT_DEFAULT, minimum=config.TIMEOUT_MIN,
help="Per-request upstream timeout. Minimum five minutes.", group="Upstream"),
ConfigField("gateway_instances", "Instances (concurrency)", type="int",
default=config.INSTANCES_DEFAULT, minimum=1, maximum=64,
help="Max concurrent upstream forwards per worker (connection pool + semaphore).",
@@ -68,8 +68,10 @@ class GatewayService(BaseService):
help="When off, the gateway is open to anyone.", group="Access"),
ConfigField("gateway_allow_admins", "Allow admins", type="bool", default=True,
help="Admin users (API key / Bearer / Basic / session) may call the gateway.", group="Access"),
ConfigField("gateway_allow_users", "Allow users", type="bool", default=False,
help="Any authenticated user may call the gateway.", group="Access"),
ConfigField("gateway_allow_users", "Allow users", type="bool", default=True,
help="Any authenticated user may call the gateway with their own API key. "
"Devii operates a signed-in user's account with that user's key, so usage is "
"attributed and limitable per user.", group="Access"),
ConfigField("gateway_access_key", "Static access key", type="password", default="", secret=True,
help="A standalone key that always grants access (sent as X-API-KEY or Bearer).",
group="Access"),
+1 -1
View File
@@ -129,7 +129,7 @@ class VisionAugmenter:
headers["X-Title"] = self.title
start = time.monotonic()
try:
resp = await client.post(self.vision_url, json=payload, headers=headers, timeout=120.0)
resp = await client.post(self.vision_url, json=payload, headers=headers)
except httpx.RequestError as e:
logger.warning("vision connection failed: %s", e)
self._record((time.monotonic() - start) * 1000, 502, False, classify_error(0, e), None)