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
@@ -205,6 +205,81 @@ ACTIONS: tuple[Action, ...] = (
summary="Delete a project",
params=(path("project_slug", "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title."),),
),
Action(
name="project_list_files",
method="GET",
path="/projects/{project_slug}/files",
summary="List every file and directory in a project filesystem",
description="Returns the flat list of nodes (path, type, size, mime). Use it to inspect the project tree before reading or writing files.",
params=(path("project_slug", "Project slug or uid."),),
requires_auth=False,
),
Action(
name="project_read_file",
method="GET",
path="/projects/{project_slug}/files/raw",
summary="Read one file from a project filesystem",
description="Returns the file metadata plus the text content. Binary files return a url instead of content.",
params=(
path("project_slug", "Project slug or uid."),
query("path", "Relative file path inside the project, e.g. src/main.py.", required=True),
),
requires_auth=False,
),
Action(
name="project_write_file",
method="POST",
path="/projects/{project_slug}/files/write",
summary="Create or overwrite a text file in a project (parent directories are created automatically)",
description="The primary tool for building a project: write any text file by path. Missing parent directories are created recursively.",
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative file path, e.g. src/app/main.py.", required=True),
body("content", "Full file content.", required=True),
),
),
Action(
name="project_upload_file",
method="POST",
path="/projects/{project_slug}/files/upload",
summary="Upload a local file into a project directory (parents created automatically)",
params=(
path("project_slug", "Project slug or uid."),
upload("file", "Local filesystem path of the file to upload."),
body("path", "Target directory inside the project, empty for the root."),
),
),
Action(
name="project_make_dir",
method="POST",
path="/projects/{project_slug}/files/mkdir",
summary="Create a directory (and parents) in a project filesystem",
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative directory path, e.g. src/components.", required=True),
),
),
Action(
name="project_move_file",
method="POST",
path="/projects/{project_slug}/files/move",
summary="Move or rename a file or directory within a project",
params=(
path("project_slug", "Project slug or uid."),
body("from_path", "Existing path.", required=True),
body("to_path", "New path.", required=True),
),
),
Action(
name="project_delete_file",
method="POST",
path="/projects/{project_slug}/files/delete",
summary="Delete a file or directory (recursive) from a project filesystem",
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative path to delete.", required=True),
),
),
Action(
name="search_users",
method="GET",
@@ -518,6 +593,7 @@ ACTIONS: tuple[Action, ...] = (
"instead of paging through admin_list_users."
),
params=(query("top_n", "How many top authors to include (1-50)."),),
requires_admin=True,
),
Action(
name="ai_usage",
@@ -535,6 +611,7 @@ ACTIONS: tuple[Action, ...] = (
query("hours", "Lookback window in hours (1-168, default 48)."),
query("top_n", "How many rows in each top-N breakdown (default 10)."),
),
requires_admin=True,
),
Action(
name="admin_list_users",
@@ -6,17 +6,32 @@ from .spec import Action
COST_ACTIONS: tuple[Action, ...] = (
Action(
name="cost_stats",
name="usage_quota",
method="LOCAL",
path="",
summary="Report token usage and cost statistics for the current session",
summary="Report the current user's AI usage as a percentage of their rolling 24h quota",
description=(
"Returns this session's LLM token counts (prompt, completion, total, cache hit/miss, "
"reasoning), the cost in USD broken down by cache-hit input, cache-miss input, and "
"output, per-request averages, cache hit rate, and session timing. Costs are priced "
"as DeepSeek V4 Flash, the cheapest official DeepSeek model."
"Returns ONLY the percentage of the rolling 24-hour AI quota the current user has "
"used, the number of turns taken today, and whether the limit is reached. It never "
"returns any cost, dollar amount, pricing, or spend figure. Use this for any "
"'how much have I used' or 'what percent of resources' question."
),
handler="cost",
requires_auth=False,
),
Action(
name="cost_stats",
method="LOCAL",
path="",
summary="Report token usage and full USD cost statistics for the current session (administrators only)",
description=(
"Administrators only. Returns this session's LLM token counts (prompt, completion, "
"total, cache hit/miss, reasoning), the cost in USD broken down by cache-hit input, "
"cache-miss input, and output, per-request averages, cache hit rate, and session "
"timing. Financial figures must never be shown to non-admin users."
),
handler="cost",
requires_auth=True,
requires_admin=True,
),
)
@@ -43,6 +43,8 @@ class Dispatcher:
agentic: AgenticController,
avatar: AvatarController | None = None,
browser: Any = None,
is_admin: bool = False,
quota_provider: Any = None,
) -> None:
self._actions = catalog.by_name()
self._client = client
@@ -51,9 +53,10 @@ class Dispatcher:
self._agentic = agentic
self._avatar = avatar
self._browser = browser
self._is_admin = is_admin
self._fetch = FetchController(settings)
self._docs = DocsController(settings)
self._cost = CostController()
self._cost = CostController(quota_provider=quota_provider)
self._chunks = ChunkController(settings)
self._rsearch = RsearchController(settings)
@@ -69,6 +72,11 @@ class Dispatcher:
"Not authenticated. Ask the user for credentials and call the login tool first.",
tool=name,
)
if action.requires_admin and not self._is_admin:
raise AuthRequiredError(
"This information is restricted to administrators.",
tool=name,
)
resource_key = self._resource_key(action, arguments)
if resource_key:
cached = serve_resource(resource_key, self._settings.max_response_chars)
+4 -2
View File
@@ -26,6 +26,7 @@ class Action:
description: str = ""
params: tuple[Param, ...] = ()
requires_auth: bool = True
requires_admin: bool = False
handler: Literal[
"http", "login", "logout", "status", "task", "agentic", "avatar", "client", "fetch",
"docs", "cost", "chunks", "rsearch"
@@ -82,9 +83,10 @@ class Catalog:
def tool_schemas(self) -> list[dict[str, Any]]:
return [action.tool_schema() for action in self.actions]
def tool_schemas_for(self, authenticated: bool) -> list[dict[str, Any]]:
def tool_schemas_for(self, authenticated: bool, is_admin: bool = False) -> list[dict[str, Any]]:
return [
action.tool_schema()
for action in self.actions
if authenticated or not action.requires_auth
if (authenticated or not action.requires_auth)
and (is_admin or not action.requires_admin)
]