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
+3
View File
@@ -9,6 +9,9 @@ MODEL_DEFAULT = INTERNAL_MODEL
INPUT_COST_PER_1M_DEFAULT = 0.27
OUTPUT_COST_PER_1M_DEFAULT = 1.10
COST_WINDOW_SECONDS = 600
COST_WARMUP_SECONDS = 120
STATE_DIR = Path.home() / ".devplace_bots"
ARTICLE_REGISTRY_PATH = Path.home() / ".dpbot_article_registry.json"
+29 -16
View File
@@ -1,7 +1,8 @@
import asyncio
import importlib
import logging
from datetime import datetime, timezone
from collections import deque
from datetime import datetime, timedelta, timezone
from devplacepy.services.base import BaseService, ConfigField
from devplacepy.services.bot import config
@@ -50,8 +51,8 @@ class BotsService(BaseService):
super().__init__(name="bots", interval_seconds=15)
self._fleet = {}
self._state_dir = config.STATE_DIR
self._cost_baseline = None
self._cost_baseline_at = None
self._cost_samples = deque()
self._cost_anchor = None
def _resolve_api_key(self, cfg: dict) -> str:
from devplacepy.database import internal_gateway_key
@@ -184,23 +185,17 @@ class BotsService(BaseService):
calls,
f"${cost:.4f}",
])
now = datetime.now(timezone.utc)
if self._started_at is None:
self._cost_baseline = None
self._cost_baseline_at = None
elif self._cost_baseline is None or self._cost_baseline[0] != self._started_at:
self._cost_baseline = (self._started_at, total_cost)
self._cost_baseline_at = now
window = (now - self._cost_baseline_at).total_seconds() if self._cost_baseline_at else 0.0
observed_cost = max(0.0, total_cost - self._cost_baseline[1]) if self._cost_baseline else 0.0
cost_per_hour = observed_cost / window * 3600 if window > 0 else 0.0
projected_24h = observed_cost / window * 86400 if window > 0 else 0.0
window, observed_cost, ready = self._sample_cost(datetime.now(timezone.utc), total_cost)
cost_per_hour = observed_cost / window * 3600 if ready else 0.0
projected_24h = observed_cost / window * 86400 if ready else 0.0
rate_value = f"${cost_per_hour:.4f}/h" if ready else "warming up"
projected_value = f"${projected_24h:.2f}" if ready else "warming up"
stats = [
{"label": "Bots running", "value": running},
{"label": "Fleet cost", "value": f"${total_cost:.4f}"},
{"label": "Observed window", "value": f"{window:.0f}s"},
{"label": "Cost rate", "value": f"${cost_per_hour:.4f}/h"},
{"label": "Projected 24h cost", "value": f"${projected_24h:.2f}"},
{"label": "Cost rate", "value": rate_value},
{"label": "Projected 24h cost", "value": projected_value},
{"label": "LLM calls", "value": total_calls},
{"label": "Tokens in", "value": total_in},
{"label": "Tokens out", "value": total_out},
@@ -213,3 +208,21 @@ class BotsService(BaseService):
"rows": rows,
}
return {"stats": stats, "table": table}
def _sample_cost(self, now: datetime, total_cost: float) -> tuple[float, float, bool]:
if self._started_at is None:
self._cost_samples.clear()
self._cost_anchor = None
return 0.0, 0.0, False
if self._cost_anchor != self._started_at:
self._cost_samples.clear()
self._cost_anchor = self._started_at
self._cost_samples.append((now, total_cost))
cutoff = now - timedelta(seconds=config.COST_WINDOW_SECONDS)
while len(self._cost_samples) > 2 and self._cost_samples[0][0] < cutoff:
self._cost_samples.popleft()
start_at, start_cost = self._cost_samples[0]
window = (now - start_at).total_seconds()
observed_cost = max(0.0, total_cost - start_cost)
ready = window >= config.COST_WARMUP_SECONDS
return window, observed_cost, ready