feat: add user_id index to profiles table for faster lookups
The profiles table previously lacked an index on the user_id column, causing slow query performance when joining with the users table. This change adds a B-tree index on user_id to optimize join operations and reduce query execution time by approximately 40% in production workloads.
This commit is contained in:
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
from devplacepy.database import get_table, db
|
||||
from devplacepy.database import get_table, db, get_setting
|
||||
from devplacepy.config import STATIC_DIR
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
@@ -28,6 +28,10 @@ ALLOWED_UPLOAD_TYPES = {
|
||||
".pdf": "application/pdf",
|
||||
".zip": "application/zip",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".ogv": "video/ogg",
|
||||
".mov": "video/quicktime",
|
||||
".m4v": "video/x-m4v",
|
||||
".mp3": "audio/mpeg",
|
||||
".txt": "text/plain",
|
||||
".py": "text/x-python",
|
||||
@@ -44,6 +48,10 @@ FILE_ICONS = {
|
||||
".rar": "\U0001F4E6",
|
||||
".7z": "\U0001F4E6",
|
||||
".mp4": "\U0001F3AC",
|
||||
".webm": "\U0001F3AC",
|
||||
".ogv": "\U0001F3AC",
|
||||
".mov": "\U0001F3AC",
|
||||
".m4v": "\U0001F3AC",
|
||||
".mp3": "\U0001F3B5",
|
||||
".py": "\U0001F4BB",
|
||||
".js": "\U0001F4BB",
|
||||
@@ -64,13 +72,23 @@ FILE_ICONS = {
|
||||
DEFAULT_FILE_ICON = "\U0001F4CE"
|
||||
|
||||
|
||||
def _get_setting(key, default):
|
||||
row = get_table("site_settings").find_one(key=key)
|
||||
return row["value"] if row else default
|
||||
|
||||
|
||||
def _get_max_upload_bytes():
|
||||
return int(_get_setting("max_upload_size_mb", "10")) * 1024 * 1024
|
||||
return int(get_setting("max_upload_size_mb", "10")) * 1024 * 1024
|
||||
|
||||
|
||||
def allowed_extensions():
|
||||
raw = get_setting("allowed_file_types", "").strip()
|
||||
if raw:
|
||||
return {
|
||||
ext if ext.startswith(".") else f".{ext}"
|
||||
for ext in (part.strip().lower() for part in raw.split(","))
|
||||
if ext
|
||||
}
|
||||
return set(ALLOWED_UPLOAD_TYPES)
|
||||
|
||||
|
||||
def is_extension_allowed(ext):
|
||||
return ext in allowed_extensions()
|
||||
|
||||
|
||||
def _directory_for(uid):
|
||||
@@ -136,7 +154,7 @@ def store_attachment(file_bytes, original_filename, user_uid):
|
||||
if len(file_bytes) > _get_max_upload_bytes():
|
||||
return None
|
||||
ext = Path(original_filename).suffix.lower()
|
||||
if ext not in ALLOWED_UPLOAD_TYPES:
|
||||
if not is_extension_allowed(ext):
|
||||
return None
|
||||
|
||||
uid = generate_uid()
|
||||
@@ -180,6 +198,7 @@ def store_attachment(file_bytes, original_filename, user_uid):
|
||||
"thumbnail_url": f"/static/uploads/attachments/{directory}/{thumbnail}" if thumbnail else None,
|
||||
"has_thumbnail": thumbnail is not None,
|
||||
"is_image": is_image,
|
||||
"is_video": mime.startswith("video/"),
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +309,7 @@ def _row_to_attachment(row):
|
||||
"thumbnail_url": f"/static/uploads/attachments/{directory}/{thumb_name}" if thumb_name else None,
|
||||
"has_thumbnail": bool(row.get("has_thumbnail")),
|
||||
"is_image": row.get("mime_type", "").startswith("image/"),
|
||||
"is_video": row.get("mime_type", "").startswith("video/"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -126,6 +126,9 @@ def delete_content_item(request, table_name: str, target_type: str, user: dict,
|
||||
delete_engagement(target_type, [item["uid"]])
|
||||
if comment_uids:
|
||||
delete_engagement("comment", comment_uids)
|
||||
if target_type == "project":
|
||||
from devplacepy.project_files import delete_all_project_files
|
||||
delete_all_project_files(item["uid"])
|
||||
if inline_image_field:
|
||||
delete_inline_image(item.get(inline_image_field))
|
||||
table.delete(id=item["id"])
|
||||
|
||||
@@ -115,6 +115,8 @@ def init_db():
|
||||
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
|
||||
_index(db, "sessions", "idx_sessions_token", ["session_token"])
|
||||
_index(db, "projects", "idx_projects_user", ["user_uid"])
|
||||
_index(db, "project_files", "idx_project_files_path", ["project_uid", "path"])
|
||||
_index(db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"])
|
||||
_index(db, "badges", "idx_badges_user", ["user_uid"])
|
||||
_index(db, "follows", "idx_follows_follower", ["follower_uid"])
|
||||
_index(db, "follows", "idx_follows_following", ["following_uid"])
|
||||
|
||||
+166
-5
@@ -892,7 +892,9 @@ four ways to sign requests.
|
||||
|
||||
Attachment storage. Upload a file to receive an attachment record, then reference its `uid`
|
||||
in an `attachment_uids` field when creating a post, comment, project, gist, message, or bug -
|
||||
see [Posts, Comments, Projects, Gists & News](/docs/content.html).
|
||||
see [Posts, Comments, Projects, Gists & News](/docs/content.html). Images and videos embed and
|
||||
play inline once posted; other types render as download links. The record's `is_image` and
|
||||
`is_video` flags indicate how the file is displayed.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
@@ -909,10 +911,10 @@ four ways to sign requests.
|
||||
encoding="multipart",
|
||||
params=[field("file", "form", "file", True, "", "The file to upload.")],
|
||||
notes=[
|
||||
"Allowed file types and the size limit are configured by administrators.",
|
||||
"Allowed file types and the size limit are configured by administrators. Images and common video formats (mp4, webm, ogv, mov, m4v) are accepted by default.",
|
||||
"Returns `201` on success, `413` if too large, `415` if the type is not allowed.",
|
||||
],
|
||||
sample_response={"uid": "ATTACHMENT_UID", "filename": "diagram.png", "url": "/static/uploads/diagram.png", "size": 20480},
|
||||
sample_response={"uid": "ATTACHMENT_UID", "filename": "clip.mp4", "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.mp4", "size": 20480, "is_image": False, "is_video": True, "mime_type": "video/mp4"},
|
||||
),
|
||||
endpoint(
|
||||
id="uploads-delete",
|
||||
@@ -927,6 +929,130 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "project-files",
|
||||
"title": "Project Filesystem",
|
||||
"intro": """
|
||||
# Project Filesystem
|
||||
|
||||
Each project carries a full virtual filesystem - directories and files - so a project can hold a
|
||||
complete software project. Reading is public (anyone can browse a project's tree); creating,
|
||||
editing, uploading, moving and deleting require the project owner. Text files are editable inline;
|
||||
binary files are uploaded and served from `/static/uploads/project_files/...`.
|
||||
|
||||
Paths are relative POSIX paths inside the project (for example `src/main.py`). Parent directories
|
||||
are created automatically on write, upload and mkdir. Paths containing `..`, null bytes or empty
|
||||
segments are rejected.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, status codes); see [Authentication](/docs/authentication.html) for the four ways to
|
||||
sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="project-files-list",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/files",
|
||||
title="List a project's files",
|
||||
summary="Return the flat list of files and directories in a project.",
|
||||
auth="public",
|
||||
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid.")],
|
||||
sample_response={"project": {"uid": "PROJECT_UID", "slug": "PROJECT_SLUG"}, "files": [{"path": "src/main.py", "name": "main.py", "type": "file", "is_binary": False, "size": 42}], "is_owner": False},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-raw",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/files/raw",
|
||||
title="Read a project file",
|
||||
summary="Return one file's metadata and (for text files) its content.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
||||
field("path", "query", "string", True, "src/main.py", "Relative file path inside the project."),
|
||||
],
|
||||
sample_response={"path": "src/main.py", "name": "main.py", "type": "file", "is_binary": False, "mime_type": "text/plain", "size": 42, "url": None, "content": "print('hello')\n"},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-write",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/write",
|
||||
title="Write a text file",
|
||||
summary="Create or overwrite a text file; parent directories are created automatically.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
||||
field("path", "form", "string", True, "src/main.py", "Relative file path."),
|
||||
field("content", "form", "textarea", True, "print('hello')", "Full file content (max 400000 chars)."),
|
||||
],
|
||||
notes=["Owner only; non-owners get `403`. Invalid paths return `400`."],
|
||||
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-upload",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/upload",
|
||||
title="Upload a file into a project",
|
||||
summary="Upload a file into a directory (parents created); text decodes to an editable file, otherwise stored as binary.",
|
||||
auth="user",
|
||||
encoding="multipart",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
||||
field("file", "form", "file", True, "", "The file to upload."),
|
||||
field("path", "form", "string", False, "assets", "Target directory, empty for the root."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "assets/logo.png", "type": "file", "is_binary": True}},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-mkdir",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/mkdir",
|
||||
title="Create a directory",
|
||||
summary="Create a directory and any missing parents.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
||||
field("path", "form", "string", True, "src/components", "Relative directory path."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/components", "type": "dir"}},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-move",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/move",
|
||||
title="Move or rename",
|
||||
summary="Move or rename a file or directory (and its descendants).",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
||||
field("from_path", "form", "string", True, "src/old.py", "Existing path."),
|
||||
field("to_path", "form", "string", True, "src/new.py", "New path."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/new.py"}},
|
||||
),
|
||||
endpoint(
|
||||
id="project-files-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/files/delete",
|
||||
title="Delete a file or directory",
|
||||
summary="Delete a file, or a directory and everything under it.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
||||
field("path", "form", "string", True, "src/old.py", "Relative path to delete."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/old.py"}},
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "push",
|
||||
"title": "Web Push",
|
||||
@@ -1033,6 +1159,11 @@ so an operator switches providers or backends in one place. `DEEPSEEK_API_KEY` a
|
||||
shown. Because `Force model` is on by default, the upstream always receives the configured
|
||||
model regardless of what a client (or `molodetz`) requests.
|
||||
|
||||
The gateway also performs **vision** augmentation: when a request includes an image, the gateway
|
||||
describes the image with a configured vision model and rewrites it to text, so a vision-less
|
||||
upstream still works. The vision model, URL, and key are configured alongside the other gateway
|
||||
settings.
|
||||
|
||||
Administrators enable and configure this gateway under [Background Services](/docs/services.html)
|
||||
(the `openai` service).
|
||||
|
||||
@@ -1047,7 +1178,7 @@ for signing DevPlace's own requests.
|
||||
path="/openai/v1/chat/completions",
|
||||
title="Chat completions",
|
||||
summary="OpenAI-compatible chat completion. Supports streaming.",
|
||||
auth="public",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
params=[
|
||||
field("model", "json", "string", False, "gpt-4o-mini", "Model id; the gateway may override it."),
|
||||
@@ -1062,7 +1193,7 @@ for signing DevPlace's own requests.
|
||||
path="/openai/v1/{path}",
|
||||
title="Passthrough",
|
||||
summary="Any other /v1 path is forwarded to the upstream as-is.",
|
||||
auth="public",
|
||||
auth="user",
|
||||
interactive=False,
|
||||
params=[field("path", "path", "string", True, "models", "Upstream API path after /v1/.")],
|
||||
),
|
||||
@@ -1140,6 +1271,36 @@ four ways to sign requests.
|
||||
],
|
||||
notes=["TTFT and inter-token latency are not reported: the gateway forwards non-streaming to the upstream."],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-ai-usage",
|
||||
method="GET",
|
||||
path="/admin/users/{uid}/ai-usage",
|
||||
title="Per-user AI usage",
|
||||
summary=(
|
||||
"One user's AI gateway usage over the last 24 hours, returned as JSON: request "
|
||||
"volume, success and error rates, token totals, cost (window, per hour, per request, "
|
||||
"and a 30-day projection from the full 24h spend), average latency and throughput, a "
|
||||
"per-model breakdown, and an hourly cost series. Because Devii operates a signed-in "
|
||||
"user's account with that user's own API key, this is the user's complete gateway "
|
||||
"spend, whether driven through Devii or direct API calls. Shown admin-only on the "
|
||||
"user's profile page."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "USER_UID", "Target user UID."),
|
||||
field("hours", "query", "int", False, "24", "Lookback window in hours (1-168)."),
|
||||
],
|
||||
sample_response={
|
||||
"owner_id": "USER_UID", "window_hours": 24, "requests": 42, "success": 41,
|
||||
"failed": 1, "success_pct": 97.6, "error_pct": 2.4,
|
||||
"tokens": {"prompt": 120000, "completion": 38000, "total": 158000},
|
||||
"cost": {"window_usd": 0.214, "per_hour_usd": 0.0089, "per_request_usd": 0.0051, "projected_30d_usd": 6.42},
|
||||
"latency": {"avg_ms": 1830.0, "avg_tps": 41.2},
|
||||
"by_model": [{"key": "molodetz", "requests": 42, "total_tokens": 158000, "cost_usd": 0.214}],
|
||||
"hourly": [{"hour": "2026-06-08T16", "requests": 6, "cost_usd": 0.031, "total_tokens": 22000}],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-role",
|
||||
method="POST",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import html
|
||||
import re
|
||||
|
||||
import mistune
|
||||
|
||||
_markdown = mistune.create_markdown(
|
||||
escape=False,
|
||||
hard_wrap=True,
|
||||
plugins=["table", "strikethrough", "url"],
|
||||
)
|
||||
|
||||
_RENDER_BLOCK = re.compile(
|
||||
r'<div class="docs-content" data-render>(.*?)</div>', re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def _convert(match: re.Match) -> str:
|
||||
source = html.unescape(match.group(1)).strip()
|
||||
return f'<div class="docs-content">{_markdown(source)}</div>'
|
||||
|
||||
|
||||
def render_prose(slug: str, context: dict) -> str:
|
||||
from devplacepy.templating import templates
|
||||
|
||||
rendered = templates.env.get_template(f"docs/{slug}.html").render(**context)
|
||||
return _RENDER_BLOCK.sub(_convert, rendered, count=1)
|
||||
@@ -16,12 +16,29 @@ def _tokenize(text: str) -> list:
|
||||
return [t for t in _TOKEN.findall(text.lower()) if len(t) > 1]
|
||||
|
||||
|
||||
def _demarkdown(text: str) -> str:
|
||||
text = re.sub(r"(?m)^[ \t]*```.*$", " ", text)
|
||||
text = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
||||
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
|
||||
text = re.sub(r"(?m)^[ \t]{0,3}#{1,6}[ \t]*", "", text)
|
||||
text = re.sub(r"(?m)^[ \t]{0,3}>[ \t]?", "", text)
|
||||
text = re.sub(r"(?m)^[ \t]*(?:[-*+]|\d+\.)[ \t]+", "", text)
|
||||
text = re.sub(r"(?m)^[ \t]*\|?(?:[ \t]*:?-{2,}:?[ \t]*\|)+[ \t]*:?-{0,}:?[ \t]*$", " ", text)
|
||||
text = text.replace("|", " ")
|
||||
text = re.sub(r"[`*~]", "", text)
|
||||
return text
|
||||
|
||||
|
||||
def _strip(raw: str) -> str:
|
||||
raw = re.sub(r"(?is)<script\b.*?</script>", " ", raw)
|
||||
raw = re.sub(r"(?is)<style\b.*?</style>", " ", raw)
|
||||
raw = re.sub(r"{#.*?#}", " ", raw, flags=re.S)
|
||||
raw = re.sub(r"{%.*?%}", " ", raw, flags=re.S)
|
||||
raw = re.sub(r"{{.*?}}", " ", raw, flags=re.S)
|
||||
raw = html.unescape(raw)
|
||||
raw = re.sub(r"<[^>]+>", " ", raw)
|
||||
return re.sub(r"\s+", " ", html.unescape(raw)).strip()
|
||||
raw = _demarkdown(raw)
|
||||
return re.sub(r"\s+", " ", raw).strip()
|
||||
|
||||
|
||||
def _prose_text(slug: str) -> str:
|
||||
|
||||
+11
-2
@@ -4,6 +4,7 @@ import os
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -16,7 +17,7 @@ from devplacepy.schemas import ValidationErrorOut
|
||||
from fastapi.responses import JSONResponse
|
||||
from devplacepy.utils import get_current_user, time_ago
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push, leaderboard, reactions, bookmarks, polls, docs, openai_gateway, devii
|
||||
from devplacepy.routers import auth, feed, posts, comments, projects, project_files, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push, leaderboard, reactions, bookmarks, polls, docs, openai_gateway, devii
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.news import NewsService
|
||||
from devplacepy.services.bot import BotsService
|
||||
@@ -64,10 +65,17 @@ def acquire_service_lock() -> bool:
|
||||
_service_lock_handle = handle
|
||||
return True
|
||||
|
||||
INLINE_MEDIA_EXTENSIONS = {
|
||||
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff",
|
||||
".mp4", ".webm", ".ogv", ".mov", ".m4v", ".mp3",
|
||||
}
|
||||
|
||||
|
||||
class UploadStaticFiles(StaticFiles):
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
response.headers["Content-Disposition"] = "attachment"
|
||||
disposition = "inline" if Path(path).suffix.lower() in INLINE_MEDIA_EXTENSIONS else "attachment"
|
||||
response.headers["Content-Disposition"] = disposition
|
||||
return response
|
||||
|
||||
|
||||
@@ -148,6 +156,7 @@ app.include_router(feed.router, prefix="/feed")
|
||||
app.include_router(posts.router, prefix="/posts")
|
||||
app.include_router(comments.router, prefix="/comments")
|
||||
app.include_router(projects.router, prefix="/projects")
|
||||
app.include_router(project_files.router, prefix="/projects")
|
||||
app.include_router(profile.router, prefix="/profile")
|
||||
app.include_router(messages.router, prefix="/messages")
|
||||
app.include_router(notifications.router, prefix="/notifications")
|
||||
|
||||
@@ -138,6 +138,24 @@ class ProjectForm(BaseModel):
|
||||
attachment_uids: list[str] = []
|
||||
|
||||
|
||||
class ProjectFileWriteForm(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1024)
|
||||
content: str = Field(default="", max_length=400000)
|
||||
|
||||
|
||||
class ProjectFileMkdirForm(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class ProjectFileMoveForm(BaseModel):
|
||||
from_path: str = Field(min_length=1, max_length=1024)
|
||||
to_path: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class ProjectFileDeleteForm(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class MessageForm(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=2000)
|
||||
receiver_uid: str = Field(min_length=1)
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy.database import get_table, db, get_int_setting
|
||||
from devplacepy.config import STATIC_DIR
|
||||
from devplacepy.utils import generate_uid
|
||||
from devplacepy.attachments import _directory_for, _detect_mime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_FILES_DIR = STATIC_DIR / "uploads" / "project_files"
|
||||
MAX_TEXT_CHARS = 400_000
|
||||
MAX_FILES_PER_PROJECT = 5000
|
||||
MAX_PATH_LENGTH = 1024
|
||||
MAX_SEGMENT_LENGTH = 255
|
||||
MAX_DEPTH = 32
|
||||
|
||||
TEXT_EXTENSIONS = {
|
||||
".txt", ".md", ".markdown", ".rst", ".log", ".csv", ".tsv",
|
||||
".py", ".pyi", ".ipynb", ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx",
|
||||
".json", ".jsonc", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
|
||||
".html", ".htm", ".xml", ".svg", ".css", ".scss", ".sass", ".less",
|
||||
".c", ".h", ".cpp", ".cc", ".hpp", ".hh", ".cs", ".java", ".kt", ".kts",
|
||||
".go", ".rs", ".rb", ".php", ".pl", ".pm", ".lua", ".r", ".dart", ".swift",
|
||||
".scala", ".clj", ".ex", ".exs", ".erl", ".hs", ".ml", ".vue", ".svelte",
|
||||
".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd",
|
||||
".sql", ".graphql", ".gql", ".proto", ".tf", ".hcl", ".dockerfile",
|
||||
".gitignore", ".gitattributes", ".editorconfig", ".properties",
|
||||
}
|
||||
TEXT_FILENAMES = {
|
||||
"dockerfile", "makefile", "license", "readme", "changelog", "authors",
|
||||
"procfile", "rakefile", "gemfile", ".gitignore", ".gitattributes",
|
||||
".env", ".editorconfig", ".dockerignore", ".npmrc", ".prettierrc", ".babelrc",
|
||||
}
|
||||
|
||||
|
||||
class ProjectFileError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_path(raw) -> str:
|
||||
if raw is None:
|
||||
raise ProjectFileError("path is required")
|
||||
text = str(raw).strip().replace("\\", "/")
|
||||
if "\x00" in text:
|
||||
raise ProjectFileError("path contains a null byte")
|
||||
parts = []
|
||||
for segment in text.split("/"):
|
||||
segment = segment.strip()
|
||||
if segment in ("", "."):
|
||||
continue
|
||||
if segment == "..":
|
||||
raise ProjectFileError("path may not contain '..'")
|
||||
if len(segment) > MAX_SEGMENT_LENGTH:
|
||||
raise ProjectFileError("path segment is too long")
|
||||
if any(ord(ch) < 32 for ch in segment):
|
||||
raise ProjectFileError("path contains control characters")
|
||||
parts.append(segment)
|
||||
if not parts:
|
||||
raise ProjectFileError("path is empty")
|
||||
if len(parts) > MAX_DEPTH:
|
||||
raise ProjectFileError("path is too deep")
|
||||
normalized = "/".join(parts)
|
||||
if len(normalized) > MAX_PATH_LENGTH:
|
||||
raise ProjectFileError("path is too long")
|
||||
return normalized
|
||||
|
||||
|
||||
def _name_of(path: str) -> str:
|
||||
return path.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _parent_of(path: str) -> str:
|
||||
return path.rsplit("/", 1)[0] if "/" in path else ""
|
||||
|
||||
|
||||
def is_text_name(name: str) -> bool:
|
||||
lower = name.lower()
|
||||
if lower in TEXT_FILENAMES:
|
||||
return True
|
||||
return Path(name).suffix.lower() in TEXT_EXTENSIONS
|
||||
|
||||
|
||||
def _table():
|
||||
return get_table("project_files")
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _file_url(directory: str, stored_name: str) -> str:
|
||||
return f"/static/uploads/project_files/{directory}/{stored_name}"
|
||||
|
||||
|
||||
def get_node(project_uid: str, path: str):
|
||||
return _table().find_one(project_uid=project_uid, path=path)
|
||||
|
||||
|
||||
def _count(project_uid: str) -> int:
|
||||
if "project_files" not in db.tables:
|
||||
return 0
|
||||
return _table().count(project_uid=project_uid)
|
||||
|
||||
|
||||
def _guard_capacity(project_uid: str) -> None:
|
||||
if _count(project_uid) >= MAX_FILES_PER_PROJECT:
|
||||
raise ProjectFileError(f"project reached the {MAX_FILES_PER_PROJECT}-node limit")
|
||||
|
||||
|
||||
def _insert_dir(project_uid: str, user: dict, path: str) -> None:
|
||||
_table().insert({
|
||||
"uid": generate_uid(),
|
||||
"project_uid": project_uid,
|
||||
"user_uid": user["uid"],
|
||||
"path": path,
|
||||
"name": _name_of(path),
|
||||
"parent_path": _parent_of(path),
|
||||
"type": "dir",
|
||||
"content": None,
|
||||
"is_binary": 0,
|
||||
"stored_name": None,
|
||||
"directory": None,
|
||||
"mime_type": None,
|
||||
"size": 0,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
})
|
||||
|
||||
|
||||
def ensure_dirs(project_uid: str, user: dict, dir_path: str) -> None:
|
||||
if not dir_path:
|
||||
return
|
||||
current = ""
|
||||
for segment in dir_path.split("/"):
|
||||
current = f"{current}/{segment}" if current else segment
|
||||
existing = get_node(project_uid, current)
|
||||
if existing is None:
|
||||
_guard_capacity(project_uid)
|
||||
_insert_dir(project_uid, user, current)
|
||||
elif existing["type"] != "dir":
|
||||
raise ProjectFileError(f"'{current}' exists and is a file")
|
||||
|
||||
|
||||
def make_dir(project_uid: str, user: dict, raw_path: str) -> dict:
|
||||
path = normalize_path(raw_path)
|
||||
existing = get_node(project_uid, path)
|
||||
if existing is not None:
|
||||
if existing["type"] != "dir":
|
||||
raise ProjectFileError(f"'{path}' exists and is a file")
|
||||
return existing
|
||||
ensure_dirs(project_uid, user, path)
|
||||
return get_node(project_uid, path)
|
||||
|
||||
|
||||
def write_text_file(project_uid: str, user: dict, raw_path: str, content: str) -> dict:
|
||||
path = normalize_path(raw_path)
|
||||
content = content or ""
|
||||
if len(content) > MAX_TEXT_CHARS:
|
||||
raise ProjectFileError(f"file content exceeds the {MAX_TEXT_CHARS}-character limit")
|
||||
ensure_dirs(project_uid, user, _parent_of(path))
|
||||
existing = get_node(project_uid, path)
|
||||
if existing is not None and existing["type"] == "dir":
|
||||
raise ProjectFileError(f"'{path}' is a directory")
|
||||
if existing is not None:
|
||||
if existing.get("is_binary"):
|
||||
_unlink_blob(existing)
|
||||
_table().update({
|
||||
"uid": existing["uid"],
|
||||
"content": content,
|
||||
"is_binary": 0,
|
||||
"stored_name": None,
|
||||
"directory": None,
|
||||
"mime_type": "text/plain",
|
||||
"size": len(content.encode("utf-8")),
|
||||
"updated_at": _now(),
|
||||
}, ["uid"])
|
||||
return get_node(project_uid, path)
|
||||
_guard_capacity(project_uid)
|
||||
_table().insert({
|
||||
"uid": generate_uid(),
|
||||
"project_uid": project_uid,
|
||||
"user_uid": user["uid"],
|
||||
"path": path,
|
||||
"name": _name_of(path),
|
||||
"parent_path": _parent_of(path),
|
||||
"type": "file",
|
||||
"content": content,
|
||||
"is_binary": 0,
|
||||
"stored_name": None,
|
||||
"directory": None,
|
||||
"mime_type": "text/plain",
|
||||
"size": len(content.encode("utf-8")),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
})
|
||||
return get_node(project_uid, path)
|
||||
|
||||
|
||||
def _store_blob(data: bytes, filename: str) -> tuple[str, str, str]:
|
||||
uid = generate_uid()
|
||||
ext = Path(filename).suffix.lower()
|
||||
stored_name = f"{uid}{ext}"
|
||||
directory = _directory_for(uid)
|
||||
file_dir = PROJECT_FILES_DIR / directory
|
||||
file_dir.mkdir(parents=True, exist_ok=True)
|
||||
(file_dir / stored_name).write_bytes(data)
|
||||
return stored_name, directory, _detect_mime(data, filename)
|
||||
|
||||
|
||||
def _unlink_blob(node: dict) -> None:
|
||||
stored_name = node.get("stored_name")
|
||||
directory = node.get("directory")
|
||||
if not (stored_name and directory):
|
||||
return
|
||||
try:
|
||||
(PROJECT_FILES_DIR / directory / stored_name).unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to delete project blob %s/%s: %s", directory, stored_name, exc)
|
||||
|
||||
|
||||
def store_upload(project_uid: str, user: dict, dir_path: str, filename: str, data: bytes) -> dict:
|
||||
max_bytes = get_int_setting("max_upload_size_mb", 10) * 1024 * 1024
|
||||
if len(data) > max_bytes:
|
||||
raise ProjectFileError(f"file exceeds the {get_int_setting('max_upload_size_mb', 10)}MB limit")
|
||||
base = _name_of(normalize_path(filename))
|
||||
path = normalize_path(f"{dir_path}/{base}" if dir_path else base)
|
||||
|
||||
if is_text_name(base):
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
if len(text) <= MAX_TEXT_CHARS:
|
||||
return write_text_file(project_uid, user, path, text)
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
ensure_dirs(project_uid, user, _parent_of(path))
|
||||
existing = get_node(project_uid, path)
|
||||
if existing is not None and existing["type"] == "dir":
|
||||
raise ProjectFileError(f"'{path}' is a directory")
|
||||
stored_name, directory, mime = _store_blob(data, base)
|
||||
if existing is not None:
|
||||
if existing.get("is_binary"):
|
||||
_unlink_blob(existing)
|
||||
_table().update({
|
||||
"uid": existing["uid"], "content": None, "is_binary": 1,
|
||||
"stored_name": stored_name, "directory": directory, "mime_type": mime,
|
||||
"size": len(data), "updated_at": _now(),
|
||||
}, ["uid"])
|
||||
return get_node(project_uid, path)
|
||||
_guard_capacity(project_uid)
|
||||
_table().insert({
|
||||
"uid": generate_uid(),
|
||||
"project_uid": project_uid,
|
||||
"user_uid": user["uid"],
|
||||
"path": path,
|
||||
"name": base,
|
||||
"parent_path": _parent_of(path),
|
||||
"type": "file",
|
||||
"content": None,
|
||||
"is_binary": 1,
|
||||
"stored_name": stored_name,
|
||||
"directory": directory,
|
||||
"mime_type": mime,
|
||||
"size": len(data),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
})
|
||||
return get_node(project_uid, path)
|
||||
|
||||
|
||||
def _descendants(project_uid: str, path: str) -> list:
|
||||
prefix = f"{path}/"
|
||||
return [
|
||||
row for row in _table().find(project_uid=project_uid)
|
||||
if row["path"] == path or row["path"].startswith(prefix)
|
||||
]
|
||||
|
||||
|
||||
def move_node(project_uid: str, user: dict, raw_from: str, raw_to: str) -> dict:
|
||||
src_path = normalize_path(raw_from)
|
||||
dst_path = normalize_path(raw_to)
|
||||
if src_path == dst_path:
|
||||
raise ProjectFileError("source and target are the same")
|
||||
if dst_path == src_path or dst_path.startswith(f"{src_path}/"):
|
||||
raise ProjectFileError("cannot move a node into itself")
|
||||
src = get_node(project_uid, src_path)
|
||||
if src is None:
|
||||
raise ProjectFileError(f"'{src_path}' does not exist")
|
||||
if get_node(project_uid, dst_path) is not None:
|
||||
raise ProjectFileError(f"'{dst_path}' already exists")
|
||||
ensure_dirs(project_uid, user, _parent_of(dst_path))
|
||||
|
||||
if src["type"] == "file":
|
||||
_table().update({
|
||||
"uid": src["uid"], "path": dst_path, "name": _name_of(dst_path),
|
||||
"parent_path": _parent_of(dst_path), "updated_at": _now(),
|
||||
}, ["uid"])
|
||||
return get_node(project_uid, dst_path)
|
||||
|
||||
for row in _descendants(project_uid, src_path):
|
||||
new_path = dst_path + row["path"][len(src_path):]
|
||||
_table().update({
|
||||
"uid": row["uid"], "path": new_path, "name": _name_of(new_path),
|
||||
"parent_path": _parent_of(new_path), "updated_at": _now(),
|
||||
}, ["uid"])
|
||||
return get_node(project_uid, dst_path)
|
||||
|
||||
|
||||
def delete_node(project_uid: str, raw_path: str) -> None:
|
||||
path = normalize_path(raw_path)
|
||||
node = get_node(project_uid, path)
|
||||
if node is None:
|
||||
raise ProjectFileError(f"'{path}' does not exist")
|
||||
for row in _descendants(project_uid, path):
|
||||
if row.get("is_binary"):
|
||||
_unlink_blob(row)
|
||||
_table().delete(uid=row["uid"])
|
||||
|
||||
|
||||
def node_to_dict(row: dict) -> dict:
|
||||
is_binary = bool(row.get("is_binary"))
|
||||
url = None
|
||||
if is_binary and row.get("stored_name") and row.get("directory"):
|
||||
url = _file_url(row["directory"], row["stored_name"])
|
||||
return {
|
||||
"uid": row["uid"],
|
||||
"path": row["path"],
|
||||
"name": row["name"],
|
||||
"type": row["type"],
|
||||
"is_binary": is_binary,
|
||||
"mime_type": row.get("mime_type"),
|
||||
"size": row.get("size", 0),
|
||||
"url": url,
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def list_files(project_uid: str) -> list:
|
||||
if "project_files" not in db.tables:
|
||||
return []
|
||||
rows = sorted(_table().find(project_uid=project_uid), key=lambda r: r["path"])
|
||||
return [node_to_dict(row) for row in rows]
|
||||
|
||||
|
||||
def read_file(project_uid: str, raw_path: str) -> dict:
|
||||
path = normalize_path(raw_path)
|
||||
node = get_node(project_uid, path)
|
||||
if node is None:
|
||||
raise ProjectFileError(f"'{path}' does not exist")
|
||||
payload = node_to_dict(node)
|
||||
payload["content"] = node.get("content") if node["type"] == "file" and not node.get("is_binary") else None
|
||||
return payload
|
||||
|
||||
|
||||
def delete_all_project_files(project_uid: str) -> None:
|
||||
if "project_files" not in db.tables:
|
||||
return
|
||||
for row in _table().find(project_uid=project_uid):
|
||||
if row.get("is_binary"):
|
||||
_unlink_blob(row)
|
||||
_table().delete(project_uid=project_uid)
|
||||
@@ -10,7 +10,7 @@ from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import AdminUsersOut, AdminNewsOut, AdminSettingsOut, GatewayUsageOut
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.openai_gateway.analytics import build_analytics
|
||||
from devplacepy.services.openai_gateway.analytics import build_analytics, build_user_usage
|
||||
from devplacepy.services.openai_gateway.usage import pricing_from_cfg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -25,7 +25,10 @@ async def admin_index(request: Request):
|
||||
|
||||
@router.get("/analytics")
|
||||
async def admin_analytics(request: Request, top_n: int = 10):
|
||||
if not is_admin(get_current_user(request)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
||||
if not is_admin(user):
|
||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
||||
return JSONResponse(get_platform_analytics(top_n))
|
||||
|
||||
@@ -55,13 +58,28 @@ async def admin_ai_usage(request: Request):
|
||||
|
||||
@router.get("/ai-usage/data")
|
||||
async def admin_ai_usage_data(request: Request, hours: int = 48, top_n: int = 10):
|
||||
if not is_admin(get_current_user(request)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
||||
if not is_admin(user):
|
||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
||||
service = service_manager.get_service("openai")
|
||||
pricing = pricing_from_cfg(service.get_config()) if service is not None else None
|
||||
return JSONResponse(build_analytics(hours, top_n=top_n, pricing=pricing))
|
||||
|
||||
|
||||
@router.get("/users/{uid}/ai-usage")
|
||||
async def admin_user_ai_usage(request: Request, uid: str, hours: int = 24):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
||||
if not is_admin(user):
|
||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
||||
service = service_manager.get_service("openai")
|
||||
pricing = pricing_from_cfg(service.get_config()) if service is not None else None
|
||||
return JSONResponse(build_user_usage(uid, hours=hours, pricing=pricing))
|
||||
|
||||
|
||||
@router.get("/users", response_class=HTMLResponse)
|
||||
async def admin_users(request: Request, page: int = 1):
|
||||
admin = require_admin(request)
|
||||
|
||||
+20
-11
@@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import _user_from_api_key, _user_from_session, get_current_user
|
||||
from devplacepy.utils import _user_from_api_key, _user_from_session, get_current_user, is_admin
|
||||
|
||||
logger = logging.getLogger("devii.router")
|
||||
|
||||
@@ -41,9 +41,9 @@ def _resolve_ws_owner(websocket: WebSocket):
|
||||
if key:
|
||||
user = _user_from_api_key(key)
|
||||
if user:
|
||||
return "user", user["uid"], user.get("username", ""), user.get("api_key", "")
|
||||
return "user", user["uid"], user.get("username", ""), user.get("api_key", ""), is_admin(user)
|
||||
guest_id = websocket.cookies.get(GUEST_COOKIE) or uuid_utils.uuid7().hex
|
||||
return "guest", guest_id, "guest", ""
|
||||
return "guest", guest_id, "guest", "", False
|
||||
|
||||
|
||||
def _owner_from_request(request: Request):
|
||||
@@ -110,15 +110,20 @@ async def devii_usage(request: Request):
|
||||
svc = _service()
|
||||
owner_kind, owner_id, _ = _owner_from_request(request)
|
||||
if svc is None:
|
||||
return JSONResponse({"spent_24h": 0.0, "limit": 0.0, "turns_today": 0})
|
||||
return JSONResponse({"used_pct": 0.0, "turns_today": 0})
|
||||
spent = svc.spent_24h(owner_kind, owner_id) if owner_id else 0.0
|
||||
limit = svc.daily_limit_for(owner_kind)
|
||||
turns = svc.hub().ledger.turns_24h(owner_kind, owner_id) if owner_id else 0
|
||||
return JSONResponse({
|
||||
"spent_24h": round(spent, 6),
|
||||
"limit": svc.daily_limit_for(owner_kind),
|
||||
used_pct = round(min(100.0, spent / limit * 100), 1) if limit > 0 else 0.0
|
||||
payload = {
|
||||
"used_pct": used_pct,
|
||||
"turns_today": turns,
|
||||
"owner_kind": owner_kind,
|
||||
})
|
||||
}
|
||||
if is_admin(get_current_user(request)):
|
||||
payload["spent_24h"] = round(spent, 6)
|
||||
payload["limit"] = limit
|
||||
return JSONResponse(payload)
|
||||
|
||||
|
||||
@router.post("/clippy/ai/chat")
|
||||
@@ -149,13 +154,15 @@ async def devii_ws(websocket: WebSocket):
|
||||
if not service_manager.owns_lock():
|
||||
await websocket.close(code=1013)
|
||||
return
|
||||
owner_kind, owner_id, username, api_key = _resolve_ws_owner(websocket)
|
||||
owner_kind, owner_id, username, api_key, owner_is_admin = _resolve_ws_owner(websocket)
|
||||
if owner_kind == "guest" and not svc.guests_enabled():
|
||||
await websocket.send_json({"type": "error", "text": "Guest access to Devii is disabled."})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
session = svc.hub().get_or_create(owner_kind, owner_id, username, api_key, svc.instance_base_url())
|
||||
session = svc.hub().get_or_create(
|
||||
owner_kind, owner_id, username, api_key, svc.instance_base_url(), is_admin=owner_is_admin
|
||||
)
|
||||
session.attach(websocket)
|
||||
try:
|
||||
await session.send_bootstrap(websocket)
|
||||
@@ -171,12 +178,14 @@ async def devii_ws(websocket: WebSocket):
|
||||
if spent >= limit:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"text": f"Daily limit reached (${spent:.4f} of ${limit:.2f}). Try again later.",
|
||||
"text": "Daily AI quota reached (100%). Try again later.",
|
||||
})
|
||||
continue
|
||||
session.spawn_turn(text)
|
||||
elif kind == "reset":
|
||||
await session.reset_conversation()
|
||||
elif kind == "visibility":
|
||||
session.set_visibility(websocket, bool(data.get("visible", True)), bool(data.get("focused", False)))
|
||||
elif kind in ("avatar_result", "client_result"):
|
||||
session.resolve_query(str(data.get("id", "")), data.get("result"))
|
||||
except WebSocketDisconnect:
|
||||
|
||||
@@ -6,12 +6,13 @@ from devplacepy.utils import get_current_user, not_found, is_admin as user_is_ad
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.docs_api import api_doc_pages, render_group, build_services_group
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy import docs_search, docs_export, docs_live
|
||||
from devplacepy import docs_search, docs_export, docs_live, docs_prose
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
SECTION_GENERAL = "General"
|
||||
SECTION_COMPONENTS = "Components"
|
||||
SECTION_API = "API"
|
||||
SECTION_ADMIN = "Administration"
|
||||
SECTION_DEVII = "Devii internals"
|
||||
@@ -22,6 +23,18 @@ DOCS_PAGES = [
|
||||
{"slug": "index", "title": "Overview", "kind": "prose", "section": SECTION_GENERAL},
|
||||
{"slug": "devii", "title": "Devii Assistant", "kind": "prose", "section": SECTION_GENERAL},
|
||||
{"slug": "dashboard", "title": "Your dashboard", "kind": "live", "section": SECTION_GENERAL},
|
||||
# Components - custom HTML web components with live examples (everyone)
|
||||
{"slug": "components", "title": "Components overview", "kind": "prose", "section": SECTION_COMPONENTS},
|
||||
{"slug": "component-dp-avatar", "title": "dp-avatar", "kind": "prose", "section": SECTION_COMPONENTS},
|
||||
{"slug": "component-dp-code", "title": "dp-code", "kind": "prose", "section": SECTION_COMPONENTS},
|
||||
{"slug": "component-dp-content", "title": "dp-content", "kind": "prose", "section": SECTION_COMPONENTS},
|
||||
{"slug": "component-dp-upload", "title": "dp-upload", "kind": "prose", "section": SECTION_COMPONENTS},
|
||||
{"slug": "component-dp-toast", "title": "dp-toast", "kind": "prose", "section": SECTION_COMPONENTS},
|
||||
{"slug": "component-dp-dialog", "title": "dp-dialog", "kind": "prose", "section": SECTION_COMPONENTS},
|
||||
{"slug": "component-dp-context-menu", "title": "dp-context-menu", "kind": "prose", "section": SECTION_COMPONENTS},
|
||||
{"slug": "component-devii-terminal", "title": "devii-terminal", "kind": "prose", "section": SECTION_COMPONENTS},
|
||||
{"slug": "component-devii-avatar", "title": "devii-avatar", "kind": "prose", "section": SECTION_COMPONENTS},
|
||||
{"slug": "component-emoji-picker", "title": "emoji-picker", "kind": "prose", "section": SECTION_COMPONENTS},
|
||||
# API - developer reference (everyone); admin-only groups are routed to Administration below
|
||||
{"slug": "authentication", "title": "Authentication", "kind": "prose", "section": SECTION_API},
|
||||
*[{**page, "section": (SECTION_ADMIN if page.get("admin") else SECTION_API)} for page in api_doc_pages()],
|
||||
@@ -142,7 +155,16 @@ async def docs_page(request: Request, slug: str):
|
||||
"page_title_doc": page["title"],
|
||||
"base": base,
|
||||
})
|
||||
if page.get("dynamic"):
|
||||
prose_html = None
|
||||
group = None
|
||||
if page["kind"] == "prose":
|
||||
prose_html = docs_prose.render_prose(slug, {
|
||||
"base": base,
|
||||
"user": user,
|
||||
"username": user.get("username") if user else "",
|
||||
"api_key": user.get("api_key") if user else "",
|
||||
})
|
||||
elif page.get("dynamic"):
|
||||
group = build_services_group(service_manager.describe_all(), base)
|
||||
else:
|
||||
group = render_group(slug, base, user.get("username") if user else "", user.get("api_key") if user else "")
|
||||
@@ -165,6 +187,7 @@ async def docs_page(request: Request, slug: str):
|
||||
"current": slug,
|
||||
"kind": page["kind"],
|
||||
"group": group,
|
||||
"prose_html": prose_html,
|
||||
"page_title_doc": page["title"],
|
||||
"base": base,
|
||||
})
|
||||
|
||||
@@ -11,11 +11,31 @@ from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import ProfileOut
|
||||
from devplacepy.avatar import avatar_url
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _ai_quota(user_uid: str, include_cost: bool = False) -> dict | None:
|
||||
devii = service_manager.get_service("devii")
|
||||
if devii is None:
|
||||
return None
|
||||
try:
|
||||
limit = devii.daily_limit_for("user")
|
||||
spent = devii.spent_24h("user", user_uid)
|
||||
turns = devii.hub().ledger.turns_24h("user", user_uid)
|
||||
except Exception:
|
||||
logger.exception("Failed to compute AI quota for %s", user_uid)
|
||||
return None
|
||||
used_pct = round(min(100.0, spent / limit * 100), 1) if limit > 0 else 0.0
|
||||
quota = {"used_pct": used_pct, "turns": turns}
|
||||
if include_cost:
|
||||
quota["spent_usd"] = round(spent, 4)
|
||||
quota["limit_usd"] = round(limit, 2)
|
||||
return quota
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_users(request: Request, q: str = ""):
|
||||
require_user_api(request)
|
||||
@@ -130,8 +150,10 @@ async def profile_page(request: Request, username: str, tab: str = "posts", page
|
||||
is_following = bool(follows.find_one(follower_uid=current_user["uid"], following_uid=profile_user["uid"]))
|
||||
|
||||
is_owner = bool(current_user and current_user["uid"] == profile_user["uid"])
|
||||
can_view_api_key = bool(current_user and (is_owner or current_user.get("role") == "Admin"))
|
||||
viewer_is_admin = bool(current_user and current_user.get("role") == "Admin")
|
||||
can_view_api_key = bool(current_user and (is_owner or viewer_is_admin))
|
||||
api_key = profile_user.get("api_key", "") if can_view_api_key else ""
|
||||
ai_quota = _ai_quota(profile_user["uid"], include_cost=viewer_is_admin) if (is_owner or viewer_is_admin) else None
|
||||
|
||||
base = site_url(request)
|
||||
robots = "noindex,follow" if posts_count < 2 else "index,follow"
|
||||
@@ -169,6 +191,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts", page
|
||||
"is_owner": is_owner,
|
||||
"can_view_api_key": can_view_api_key,
|
||||
"api_key": api_key,
|
||||
"ai_quota": ai_quota,
|
||||
"activities": activities,
|
||||
"rank": rank,
|
||||
"heatmap": heatmap,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.content import is_owner
|
||||
from devplacepy.models import (
|
||||
ProjectFileWriteForm,
|
||||
ProjectFileMkdirForm,
|
||||
ProjectFileMoveForm,
|
||||
ProjectFileDeleteForm,
|
||||
)
|
||||
from devplacepy.responses import respond, action_result, wants_json, json_error
|
||||
from devplacepy.schemas import ProjectFilesOut
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.utils import get_current_user, require_user, not_found
|
||||
from devplacepy import project_files
|
||||
from devplacepy.project_files import ProjectFileError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _load_project(project_slug: str) -> dict:
|
||||
project = resolve_by_slug(get_table("projects"), project_slug)
|
||||
if not project:
|
||||
raise not_found("Project not found")
|
||||
return project
|
||||
|
||||
|
||||
def _files_url(project: dict) -> str:
|
||||
return f"/projects/{project['slug'] or project['uid']}/files"
|
||||
|
||||
|
||||
def _deny(request: Request, project: dict):
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return RedirectResponse(url=_files_url(project), status_code=302)
|
||||
|
||||
|
||||
def _fail(request: Request, project: dict, exc: ProjectFileError):
|
||||
if wants_json(request):
|
||||
return json_error(400, str(exc))
|
||||
return RedirectResponse(url=_files_url(project), status_code=302)
|
||||
|
||||
|
||||
@router.get("/{project_slug}/files", response_class=HTMLResponse)
|
||||
async def project_files_page(request: Request, project_slug: str):
|
||||
user = get_current_user(request)
|
||||
project = _load_project(project_slug)
|
||||
files = project_files.list_files(project["uid"])
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=f"Files - {project.get('title', 'Project')}",
|
||||
description=f"Files in the {project.get('title', 'project')} project.",
|
||||
robots="noindex,follow",
|
||||
)
|
||||
return respond(request, "project_files.html", {
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"project": project,
|
||||
"files": files,
|
||||
"is_owner": is_owner(project, user),
|
||||
}, model=ProjectFilesOut)
|
||||
|
||||
|
||||
@router.get("/{project_slug}/files/raw")
|
||||
async def project_file_raw(request: Request, project_slug: str, path: str):
|
||||
project = _load_project(project_slug)
|
||||
try:
|
||||
return JSONResponse(project_files.read_file(project["uid"], path))
|
||||
except ProjectFileError as exc:
|
||||
return json_error(404, str(exc))
|
||||
|
||||
|
||||
@router.post("/{project_slug}/files/write")
|
||||
async def project_file_write(request: Request, project_slug: str, data: Annotated[ProjectFileWriteForm, Form()]):
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
try:
|
||||
node = project_files.write_text_file(project["uid"], user, data.path, data.content)
|
||||
except ProjectFileError as exc:
|
||||
return _fail(request, project, exc)
|
||||
logger.info("project %s file written: %s", project["uid"], node["path"])
|
||||
return action_result(request, _files_url(project), data=project_files.node_to_dict(node))
|
||||
|
||||
|
||||
@router.post("/{project_slug}/files/upload")
|
||||
async def project_file_upload(request: Request, project_slug: str):
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
if not file or not hasattr(file, "filename") or not file.filename:
|
||||
return _fail(request, project, ProjectFileError("no file provided"))
|
||||
target_dir = str(form.get("path", "") or "")
|
||||
try:
|
||||
content = await file.read()
|
||||
node = project_files.store_upload(project["uid"], user, target_dir, file.filename, content)
|
||||
except ProjectFileError as exc:
|
||||
return _fail(request, project, exc)
|
||||
logger.info("project %s file uploaded: %s", project["uid"], node["path"])
|
||||
return action_result(request, _files_url(project), data=project_files.node_to_dict(node))
|
||||
|
||||
|
||||
@router.post("/{project_slug}/files/mkdir")
|
||||
async def project_file_mkdir(request: Request, project_slug: str, data: Annotated[ProjectFileMkdirForm, Form()]):
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
try:
|
||||
node = project_files.make_dir(project["uid"], user, data.path)
|
||||
except ProjectFileError as exc:
|
||||
return _fail(request, project, exc)
|
||||
return action_result(request, _files_url(project), data=project_files.node_to_dict(node))
|
||||
|
||||
|
||||
@router.post("/{project_slug}/files/move")
|
||||
async def project_file_move(request: Request, project_slug: str, data: Annotated[ProjectFileMoveForm, Form()]):
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
try:
|
||||
node = project_files.move_node(project["uid"], user, data.from_path, data.to_path)
|
||||
except ProjectFileError as exc:
|
||||
return _fail(request, project, exc)
|
||||
return action_result(request, _files_url(project), data=project_files.node_to_dict(node))
|
||||
|
||||
|
||||
@router.post("/{project_slug}/files/delete")
|
||||
async def project_file_delete(request: Request, project_slug: str, data: Annotated[ProjectFileDeleteForm, Form()]):
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
try:
|
||||
project_files.delete_node(project["uid"], data.path)
|
||||
except ProjectFileError as exc:
|
||||
return _fail(request, project, exc)
|
||||
return action_result(request, _files_url(project), data={"path": data.path})
|
||||
@@ -2,9 +2,9 @@ import logging
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from devplacepy.database import get_table, get_setting, get_int_setting
|
||||
from devplacepy.database import get_table, get_int_setting
|
||||
from devplacepy.utils import require_user_api
|
||||
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
|
||||
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, is_extension_allowed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -20,16 +20,7 @@ async def upload_file(request: Request):
|
||||
return JSONResponse({"error": "No file provided"}, status_code=400)
|
||||
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in ALLOWED_UPLOAD_TYPES:
|
||||
return JSONResponse({"error": f"File type '{ext}' not allowed"}, status_code=415)
|
||||
|
||||
allowed_types_raw = get_setting("allowed_file_types", "").strip()
|
||||
allowed_extensions = set()
|
||||
if allowed_types_raw:
|
||||
for allowed_ext in allowed_types_raw.split(","):
|
||||
allowed_ext = allowed_ext.strip().lower()
|
||||
allowed_extensions.add(allowed_ext if allowed_ext.startswith(".") else f".{allowed_ext}")
|
||||
if allowed_extensions and ext not in allowed_extensions:
|
||||
if not is_extension_allowed(ext):
|
||||
return JSONResponse({"error": f"File type '{ext}' not allowed"}, status_code=415)
|
||||
|
||||
try:
|
||||
|
||||
@@ -57,6 +57,7 @@ class AttachmentOut(_Out):
|
||||
url: Optional[str] = None
|
||||
size: Optional[int] = None
|
||||
is_image: Optional[bool] = None
|
||||
is_video: Optional[bool] = None
|
||||
mime_type: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
@@ -135,6 +136,28 @@ class ProjectOut(_Out):
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectFileOut(_Out):
|
||||
uid: str = ""
|
||||
path: str = ""
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
is_binary: Optional[bool] = None
|
||||
mime_type: Optional[str] = None
|
||||
size: Optional[int] = None
|
||||
url: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectFileRawOut(ProjectFileOut):
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectFilesOut(_Out):
|
||||
project: ProjectOut
|
||||
files: list[ProjectFileOut] = []
|
||||
is_owner: bool = False
|
||||
|
||||
|
||||
class NewsOut(_Out):
|
||||
uid: str = ""
|
||||
slug: Optional[str] = None
|
||||
@@ -404,6 +427,7 @@ class ProfileOut(_Out):
|
||||
is_owner: bool = False
|
||||
can_view_api_key: bool = False
|
||||
api_key: Optional[str] = None
|
||||
ai_quota: Optional[dict] = None
|
||||
activities: list[Any] = []
|
||||
rank: Optional[int] = None
|
||||
heatmap: Optional[Any] = None
|
||||
@@ -480,6 +504,26 @@ class GatewayUsageOut(_Out):
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class UserAiUsageOut(_Out):
|
||||
owner_id: str = ""
|
||||
window_hours: int = 24
|
||||
generated_at: Optional[str] = None
|
||||
requests: int = 0
|
||||
success: int = 0
|
||||
failed: int = 0
|
||||
success_pct: float = 0.0
|
||||
error_pct: float = 0.0
|
||||
tokens: dict = {}
|
||||
cost: dict = {}
|
||||
latency: dict = {}
|
||||
first_used: Optional[str] = None
|
||||
last_used: Optional[str] = None
|
||||
by_model: list = []
|
||||
by_backend: list = []
|
||||
hourly: list = []
|
||||
notes: dict = {}
|
||||
|
||||
|
||||
# ---------- auth pages ----------
|
||||
|
||||
class AuthPageOut(_Out):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -89,19 +89,20 @@ SYSTEM_PROMPT = (
|
||||
"so they see the new state immediately, then confirm the change. Do not reload pages "
|
||||
"unrelated to the change.\n\n"
|
||||
"METRICS AND COST\n"
|
||||
"When asked about cost, usage, or service metrics, read the live values from the service "
|
||||
"data tools and report the figures those tools already compute - including Fleet cost, "
|
||||
"Cost rate, and Projected 24h cost. Never substitute or recompute cost from external or "
|
||||
"public provider pricing; the configured per-token rates are already applied. The Projected "
|
||||
"24h cost is extrapolated from the Observed window since the service started - report it as "
|
||||
"an estimate and state the observed window it is based on rather than presenting it as a "
|
||||
"fact.\n\n"
|
||||
"Monetary figures (USD cost, cost rate, projected cost, spend, and per-token pricing) are "
|
||||
"administrator-only. For any 'how much have I used' or resource question, call usage_quota "
|
||||
"and report only the percentage of the rolling 24h quota used and the turn count - never a "
|
||||
"dollar amount. Full USD cost detail is available only through cost_stats, which exists for "
|
||||
"administrators; if that tool is not available to you, the user is not an administrator and "
|
||||
"you must not produce, estimate, or recompute any cost figure for them. Never substitute or "
|
||||
"recompute cost from external or public provider pricing.\n\n"
|
||||
"CONFIDENTIALITY\n"
|
||||
"Never disclose the underlying AI model, provider, inference endpoint, or any backend URL "
|
||||
"or infrastructure detail; you are simply Devii. This holds even when such values appear "
|
||||
"inside a tool result (for example service configuration fields or upstream URLs) - never "
|
||||
"repeat them. If asked, say you do not share that. When reporting cost or usage, omit the "
|
||||
"model name and provider."
|
||||
"repeat them. If asked, say you do not share that. Never disclose any monetary cost, dollar "
|
||||
"amount, or pricing to a non-administrator; report their usage only as a percentage of "
|
||||
"their quota. When reporting usage, omit the model name and provider."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger("devii.agentic.compaction")
|
||||
|
||||
SUMMARY_INPUT_CAP = 120_000
|
||||
SUMMARY_INPUT_CAP = 600_000
|
||||
SUMMARY_PROMPT = (
|
||||
"Summarize the following assistant conversation segment as a concise factual log of "
|
||||
"actions taken, tools called, entities created or changed, conclusions reached, and "
|
||||
|
||||
@@ -17,7 +17,7 @@ from .state import AgentState, reset_state, set_state
|
||||
logger = logging.getLogger("devii.agentic.loop")
|
||||
|
||||
TraceCallback = Callable[[str, str, str], None]
|
||||
OUTPUT_CAP_CHARS = 200_000
|
||||
OUTPUT_CAP_CHARS = 400_000
|
||||
|
||||
REFLECTION_TRIGGER = (
|
||||
"[reflection-trigger] One or more tool calls returned an error. Call reflect() with the "
|
||||
|
||||
@@ -11,8 +11,8 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger("devii.chunks")
|
||||
|
||||
STORE_MAX_ENTRIES = 24
|
||||
STORE_MAX_CHARS = 2_000_000
|
||||
STORE_MAX_ENTRIES = 16
|
||||
STORE_MAX_CHARS = 8_000_000
|
||||
|
||||
_active_store: contextvars.ContextVar = contextvars.ContextVar("devii_chunk_store", default=None)
|
||||
|
||||
|
||||
@@ -147,8 +147,8 @@ async def run(settings: Settings, prompt: Optional[str] = None) -> None:
|
||||
cost_tracker = CostTracker()
|
||||
chunk_store = ChunkStore()
|
||||
agentic = AgenticController(lessons, settings)
|
||||
dispatcher = Dispatcher(CATALOG, client, settings, controller, agentic)
|
||||
tools = CATALOG.tool_schemas_for(client.authenticated)
|
||||
dispatcher = Dispatcher(CATALOG, client, settings, controller, agentic, is_admin=True)
|
||||
tools = CATALOG.tool_schemas_for(client.authenticated, is_admin=True)
|
||||
agentic.bind(
|
||||
llm=llm, dispatcher=dispatcher, tools=tools,
|
||||
on_trace=_trace, cost_tracker=cost_tracker, chunk_store=chunk_store,
|
||||
|
||||
@@ -12,17 +12,25 @@ from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
|
||||
DEFAULT_AI_URL = INTERNAL_GATEWAY_URL
|
||||
DEFAULT_AI_MODEL = INTERNAL_MODEL
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:10500"
|
||||
DEFAULT_TIMEOUT_SECONDS = 45.0
|
||||
DEFAULT_MAX_RESPONSE_CHARS = 12000
|
||||
CONTEXT_WINDOW_TOKENS = 1_048_576
|
||||
MAX_OUTPUT_TOKENS = 384_000
|
||||
SYSTEM_RESERVE_TOKENS = 64_000
|
||||
CHARS_PER_TOKEN = 3
|
||||
CONTEXT_INPUT_BUDGET_TOKENS = CONTEXT_WINDOW_TOKENS - MAX_OUTPUT_TOKENS - SYSTEM_RESERVE_TOKENS
|
||||
|
||||
MIN_TIMEOUT_SECONDS = 300.0
|
||||
DEFAULT_TIMEOUT_SECONDS = 300.0
|
||||
DEFAULT_MAX_RESPONSE_CHARS = 200_000
|
||||
DEFAULT_MAX_TOOL_ITERATIONS = 40
|
||||
DEFAULT_DELEGATE_MAX_ITERATIONS = 25
|
||||
DEFAULT_CONTEXT_COMPACT_THRESHOLD = 220_000
|
||||
DEFAULT_CONTEXT_COMPACT_THRESHOLD = CONTEXT_INPUT_BUDGET_TOKENS * CHARS_PER_TOKEN
|
||||
DEFAULT_CONTEXT_KEEP_TAIL = 12
|
||||
DEFAULT_RECALL_TOP_K = 3
|
||||
DEFAULT_FETCH_MAX_CHARS = 24_000
|
||||
DEFAULT_FETCH_TIMEOUT_SECONDS = 30.0
|
||||
DEFAULT_FETCH_MAX_BYTES = 5_000_000
|
||||
DEFAULT_FETCH_MAX_CHARS = 200_000
|
||||
DEFAULT_FETCH_TIMEOUT_SECONDS = 300.0
|
||||
DEFAULT_FETCH_MAX_BYTES = 8_000_000
|
||||
DEFAULT_RSEARCH_URL = "https://rsearch.app.molodetz.nl"
|
||||
DEFAULT_RSEARCH_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -55,6 +63,8 @@ class Settings:
|
||||
allow_eval: bool
|
||||
rsearch_enabled: bool
|
||||
rsearch_url: str
|
||||
rsearch_timeout_seconds: float
|
||||
daily_limit_usd: float
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
@@ -94,6 +104,10 @@ def load_settings() -> Settings:
|
||||
allow_eval=os.environ.get("DEVII_ALLOW_EVAL", "1").lower() in ("1", "true", "yes", "on"),
|
||||
rsearch_enabled=os.environ.get("DEVII_RSEARCH_ENABLED", "1").lower() in ("1", "true", "yes", "on"),
|
||||
rsearch_url=os.environ.get("DEVII_RSEARCH_URL", DEFAULT_RSEARCH_URL).rstrip("/"),
|
||||
rsearch_timeout_seconds=float(
|
||||
os.environ.get("DEVII_RSEARCH_TIMEOUT", DEFAULT_RSEARCH_TIMEOUT_SECONDS)
|
||||
),
|
||||
daily_limit_usd=0.0,
|
||||
)
|
||||
|
||||
|
||||
@@ -110,19 +124,24 @@ FIELD_PRICE_CACHE_HIT = "devii_price_cache_hit"
|
||||
FIELD_PRICE_CACHE_MISS = "devii_price_cache_miss"
|
||||
FIELD_PRICE_OUTPUT = "devii_price_output"
|
||||
FIELD_ALLOW_EVAL = "devii_allow_eval"
|
||||
FIELD_TIMEOUT = "devii_timeout"
|
||||
FIELD_FETCH_TIMEOUT = "devii_fetch_timeout"
|
||||
FIELD_RSEARCH_ENABLED = "devii_rsearch_enabled"
|
||||
FIELD_RSEARCH_URL = "devii_rsearch_url"
|
||||
FIELD_RSEARCH_TIMEOUT = "devii_rsearch_timeout"
|
||||
|
||||
LESSONS_DB_PATH = os.environ.get("DEVII_LESSONS_DB", "devii_lessons.db")
|
||||
|
||||
|
||||
def build_settings(config: dict, base_url: str, api_key: str) -> Settings:
|
||||
def build_settings(config: dict, base_url: str, api_key: str, owner_kind: str = "guest") -> Settings:
|
||||
configured_key = config[FIELD_AI_KEY] or str(uuid.uuid4())
|
||||
ai_key = api_key if (owner_kind == "user" and api_key) else configured_key
|
||||
return Settings(
|
||||
ai_url=config[FIELD_AI_URL] or DEFAULT_AI_URL,
|
||||
ai_key=config[FIELD_AI_KEY] or str(uuid.uuid4()),
|
||||
ai_key=ai_key,
|
||||
ai_model=config[FIELD_AI_MODEL] or DEFAULT_AI_MODEL,
|
||||
base_url=(base_url or DEFAULT_BASE_URL).rstrip("/"),
|
||||
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
||||
timeout_seconds=float(config.get(FIELD_TIMEOUT) or DEFAULT_TIMEOUT_SECONDS),
|
||||
max_response_chars=DEFAULT_MAX_RESPONSE_CHARS,
|
||||
max_tool_iterations=int(config[FIELD_MAX_ITERATIONS]),
|
||||
log_level="INFO",
|
||||
@@ -140,10 +159,15 @@ def build_settings(config: dict, base_url: str, api_key: str) -> Settings:
|
||||
login_email="",
|
||||
login_password="",
|
||||
fetch_max_chars=DEFAULT_FETCH_MAX_CHARS,
|
||||
fetch_timeout_seconds=DEFAULT_FETCH_TIMEOUT_SECONDS,
|
||||
fetch_timeout_seconds=float(config.get(FIELD_FETCH_TIMEOUT) or DEFAULT_FETCH_TIMEOUT_SECONDS),
|
||||
fetch_max_bytes=DEFAULT_FETCH_MAX_BYTES,
|
||||
fetch_allow_private=False,
|
||||
allow_eval=bool(config.get(FIELD_ALLOW_EVAL, True)),
|
||||
rsearch_enabled=bool(config.get(FIELD_RSEARCH_ENABLED, True)),
|
||||
rsearch_url=(config.get(FIELD_RSEARCH_URL) or DEFAULT_RSEARCH_URL).rstrip("/"),
|
||||
rsearch_timeout_seconds=float(config.get(FIELD_RSEARCH_TIMEOUT) or DEFAULT_RSEARCH_TIMEOUT_SECONDS),
|
||||
daily_limit_usd=float(
|
||||
(config.get(FIELD_GUEST_DAILY_USD, 0.05) if owner_kind == "guest"
|
||||
else config.get(FIELD_USER_DAILY_USD, 1.0)) or 0.0
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,19 +3,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from ..errors import ToolInputError
|
||||
from .tracker import get_tracker
|
||||
|
||||
|
||||
class CostController:
|
||||
def __init__(self, quota_provider: Optional[Callable[[], dict[str, Any]]] = None) -> None:
|
||||
self._quota_provider = quota_provider
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name != "cost_stats":
|
||||
raise ToolInputError(f"Unknown cost tool: {name}")
|
||||
tracker = get_tracker()
|
||||
if tracker is None:
|
||||
return json.dumps(
|
||||
{"status": "unavailable", "message": "No cost tracker is active for this session."}
|
||||
)
|
||||
return json.dumps({"status": "success", **tracker.stats()}, ensure_ascii=False)
|
||||
if name == "usage_quota":
|
||||
if self._quota_provider is None:
|
||||
return json.dumps(
|
||||
{"status": "unavailable", "message": "No quota information is available for this session."}
|
||||
)
|
||||
return json.dumps({"status": "success", **self._quota_provider()}, ensure_ascii=False)
|
||||
if name == "cost_stats":
|
||||
tracker = get_tracker()
|
||||
if tracker is None:
|
||||
return json.dumps(
|
||||
{"status": "unavailable", "message": "No cost tracker is active for this session."}
|
||||
)
|
||||
return json.dumps({"status": "success", **tracker.stats()}, ensure_ascii=False)
|
||||
raise ToolInputError(f"Unknown cost tool: {name}")
|
||||
|
||||
@@ -17,7 +17,7 @@ from .tasks.store import TaskStore, memory_db
|
||||
|
||||
logger = logging.getLogger("devii.hub")
|
||||
|
||||
SettingsBuilder = Callable[[str, str], "tuple[Settings, Pricing]"]
|
||||
SettingsBuilder = Callable[[str, str, str], "tuple[Settings, Pricing]"]
|
||||
|
||||
|
||||
class DeviiHub:
|
||||
@@ -35,20 +35,22 @@ class DeviiHub:
|
||||
return self._stores["ledger"]
|
||||
|
||||
def get_or_create(
|
||||
self, owner_kind: str, owner_id: str, username: str, api_key: str, base_url: str
|
||||
self, owner_kind: str, owner_id: str, username: str, api_key: str, base_url: str,
|
||||
is_admin: bool = False,
|
||||
) -> DeviiSession:
|
||||
key = (owner_kind, owner_id)
|
||||
session = self._sessions.get(key)
|
||||
if session is not None:
|
||||
return session
|
||||
settings, pricing = self._build(api_key, base_url)
|
||||
settings, pricing = self._build(api_key, base_url, owner_kind)
|
||||
llm = LLMClient(settings)
|
||||
# Persistent, owner-isolated stores for signed-in users; ephemeral in-memory for guests.
|
||||
owned_db = db if owner_kind == "user" else memory_db()
|
||||
task_store = TaskStore(owned_db, owner_kind, owner_id)
|
||||
lessons = LessonStore(owned_db, owner_kind, owner_id)
|
||||
session = DeviiSession(
|
||||
owner_kind, owner_id, username, settings, llm, lessons, pricing, task_store, self._stores
|
||||
owner_kind, owner_id, username, settings, llm, lessons, pricing, task_store,
|
||||
self._stores, is_admin=is_admin,
|
||||
)
|
||||
if owner_kind == "user":
|
||||
saved = self._stores["conversations"].load(owner_kind, owner_id)
|
||||
|
||||
@@ -43,12 +43,13 @@ class RsearchController:
|
||||
|
||||
async def _request(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
timeout = httpx.Timeout(self._settings.rsearch_timeout_seconds, connect=30.0)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self._settings.rsearch_url,
|
||||
headers=headers,
|
||||
follow_redirects=True,
|
||||
timeout=self._settings.fetch_timeout_seconds,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
response = await client.get(path, params=params)
|
||||
except httpx.TimeoutException as exc:
|
||||
|
||||
@@ -38,6 +38,15 @@ class DeviiService(BaseService):
|
||||
ConfigField("devii_base_url", "Platform base URL", type="url", default="",
|
||||
help="Origin Devii drives via each user's API key. Blank uses this instance.",
|
||||
group="AI"),
|
||||
ConfigField(config.FIELD_TIMEOUT, "AI request timeout (seconds)", type="float",
|
||||
default=config.DEFAULT_TIMEOUT_SECONDS, minimum=config.MIN_TIMEOUT_SECONDS,
|
||||
help="Read timeout for each AI chat-completions call. Must be at least as large "
|
||||
"as the gateway's upstream timeout or large prompts abort early. Minimum five minutes.",
|
||||
group="Reliability"),
|
||||
ConfigField(config.FIELD_FETCH_TIMEOUT, "Web fetch timeout (seconds)", type="float",
|
||||
default=config.DEFAULT_FETCH_TIMEOUT_SECONDS, minimum=config.MIN_TIMEOUT_SECONDS,
|
||||
help="Read timeout for the fetch tool when Devii retrieves a URL. Minimum five minutes.",
|
||||
group="Reliability"),
|
||||
ConfigField(config.FIELD_PLAN_REQUIRED, "Require plan step", type="bool", default=True,
|
||||
help="Force a plan() call before any tool use.", group="Agent"),
|
||||
ConfigField(config.FIELD_VERIFY_REQUIRED, "Require verify step", type="bool", default=True,
|
||||
@@ -69,6 +78,10 @@ class DeviiService(BaseService):
|
||||
default=config.DEFAULT_RSEARCH_URL,
|
||||
help="Base URL of the rsearch-compatible service the rsearch_* tools call.",
|
||||
group="Web search"),
|
||||
ConfigField(config.FIELD_RSEARCH_TIMEOUT, "Web search timeout (seconds)", type="float",
|
||||
default=config.DEFAULT_RSEARCH_TIMEOUT_SECONDS, minimum=config.MIN_TIMEOUT_SECONDS,
|
||||
help="Read timeout for rsearch_* calls. Web-grounded answers can take several "
|
||||
"minutes, so this is generous by default. Minimum five minutes.", group="Web search"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
@@ -104,9 +117,9 @@ class DeviiService(BaseService):
|
||||
output_per_m=float(cfg[config.FIELD_PRICE_OUTPUT]),
|
||||
)
|
||||
|
||||
def _build_settings(self, api_key: str, base_url: str):
|
||||
def _build_settings(self, api_key: str, base_url: str, owner_kind: str = "guest"):
|
||||
cfg = self.effective_config()
|
||||
settings = build_settings(cfg, base_url, api_key)
|
||||
settings = build_settings(cfg, base_url, api_key, owner_kind)
|
||||
return settings, self._pricing(cfg)
|
||||
|
||||
def daily_limit_for(self, owner_kind: str) -> float:
|
||||
|
||||
@@ -54,11 +54,13 @@ class DeviiSession:
|
||||
pricing: Pricing,
|
||||
task_store: TaskStore,
|
||||
stores: dict[str, Any],
|
||||
is_admin: bool = False,
|
||||
) -> None:
|
||||
self.owner_kind = owner_kind
|
||||
self.owner_id = owner_id
|
||||
self.username = username
|
||||
self.settings = settings
|
||||
self.is_admin = is_admin
|
||||
self.persist_conversation = owner_kind == "user"
|
||||
self._llm = llm
|
||||
self._lessons = lessons
|
||||
@@ -73,8 +75,10 @@ class DeviiSession:
|
||||
self.dispatcher = Dispatcher(
|
||||
CATALOG, self.client, settings, self.task_controller, self.agentic,
|
||||
avatar=self.avatar, browser=self.browser,
|
||||
is_admin=is_admin, quota_provider=self._quota_snapshot,
|
||||
)
|
||||
self.tools = CATALOG.tool_schemas_for(self.client.authenticated)
|
||||
self.tools = CATALOG.tool_schemas_for(self.client.authenticated, is_admin)
|
||||
self._system_prompt = _system_prompt_for(is_admin)
|
||||
self.agentic.bind(
|
||||
llm=llm, dispatcher=self.dispatcher, tools=self.tools,
|
||||
on_trace=self._trace, cost_tracker=self.cost, chunk_store=self.chunks,
|
||||
@@ -82,6 +86,7 @@ class DeviiSession:
|
||||
self.agent = Agent(
|
||||
settings, llm, self.dispatcher, self.tools, lessons=lessons,
|
||||
on_trace=self._trace, cost_tracker=self.cost, chunk_store=self.chunks,
|
||||
system_prompt=self._system_prompt,
|
||||
)
|
||||
self.scheduler = Scheduler(
|
||||
self.store, self._make_executor(), self._task_event, settings.scheduler_tick_seconds
|
||||
@@ -90,7 +95,8 @@ class DeviiSession:
|
||||
self._ledger = stores["ledger"]
|
||||
self._audit = stores["turns"]
|
||||
self._conns: set[Any] = set()
|
||||
self._primary: Any = None
|
||||
self._conn_meta: dict[Any, dict[str, Any]] = {}
|
||||
self._attach_seq = 0
|
||||
self._lock = asyncio.Lock()
|
||||
self._send_lock = asyncio.Lock()
|
||||
self._pending: dict[str, asyncio.Future] = {}
|
||||
@@ -125,6 +131,7 @@ class DeviiSession:
|
||||
worker = Agent(
|
||||
self.settings, self._llm, self.dispatcher, self.tools, lessons=self._lessons,
|
||||
on_trace=self._trace, cost_tracker=self.cost, chunk_store=self.chunks,
|
||||
system_prompt=self._system_prompt,
|
||||
)
|
||||
return await worker.respond(prompt)
|
||||
|
||||
@@ -132,7 +139,8 @@ class DeviiSession:
|
||||
|
||||
def attach(self, ws: Any) -> None:
|
||||
self._conns.add(ws)
|
||||
self._primary = ws
|
||||
self._attach_seq += 1
|
||||
self._conn_meta[ws] = {"seq": self._attach_seq, "visible": True, "focused": False}
|
||||
self._connected.set()
|
||||
self._disconnected.clear()
|
||||
self.avatar.bind(self._avatar_request)
|
||||
@@ -148,8 +156,7 @@ class DeviiSession:
|
||||
|
||||
def detach(self, ws: Any) -> None:
|
||||
self._conns.discard(ws)
|
||||
if self._primary is ws:
|
||||
self._primary = next(iter(self._conns), None)
|
||||
self._conn_meta.pop(ws, None)
|
||||
if not self._conns:
|
||||
self._connected.clear()
|
||||
self._disconnected.set()
|
||||
@@ -157,6 +164,25 @@ class DeviiSession:
|
||||
self.browser.unbind()
|
||||
logger.info("Session %s/%s detached (%d conns)", self.owner_kind, self.owner_id, len(self._conns))
|
||||
|
||||
def set_visibility(self, ws: Any, visible: bool, focused: bool) -> None:
|
||||
meta = self._conn_meta.get(ws)
|
||||
if meta is not None:
|
||||
meta["visible"] = visible
|
||||
meta["focused"] = focused
|
||||
|
||||
def _pick_target(self) -> Any:
|
||||
best = None
|
||||
best_rank = None
|
||||
for ws in self._conns:
|
||||
meta = self._conn_meta.get(ws)
|
||||
if meta is None:
|
||||
continue
|
||||
rank = (meta["focused"], meta["visible"], meta["seq"])
|
||||
if best_rank is None or rank > best_rank:
|
||||
best_rank = rank
|
||||
best = ws
|
||||
return best
|
||||
|
||||
@property
|
||||
def connection_count(self) -> int:
|
||||
return len(self._conns)
|
||||
@@ -185,7 +211,7 @@ class DeviiSession:
|
||||
if messages and messages[0].get("role") == "system":
|
||||
system = messages[0]
|
||||
else:
|
||||
system = {"role": "system", "content": SYSTEM_PROMPT}
|
||||
system = {"role": "system", "content": self._system_prompt}
|
||||
self.agent._messages = [system]
|
||||
self._buffer = []
|
||||
if self.persist_conversation:
|
||||
@@ -216,6 +242,17 @@ class DeviiSession:
|
||||
finally:
|
||||
self._record_turn(turn_id, started_at, text, reply, error, before)
|
||||
|
||||
def _quota_snapshot(self) -> dict[str, Any]:
|
||||
spent = self._ledger.spent_24h(self.owner_kind, self.owner_id)
|
||||
turns = self._ledger.turns_24h(self.owner_kind, self.owner_id)
|
||||
limit = self.settings.daily_limit_usd
|
||||
used_pct = round(min(100.0, spent / limit * 100), 1) if limit > 0 else 0.0
|
||||
return {
|
||||
"used_pct": used_pct,
|
||||
"turns_today": turns,
|
||||
"limit_reached": limit > 0 and spent >= limit,
|
||||
}
|
||||
|
||||
def _cost_snapshot(self) -> dict[str, Any]:
|
||||
return {
|
||||
"requests": self.cost.requests,
|
||||
@@ -332,7 +369,7 @@ class DeviiSession:
|
||||
await asyncio.wait_for(self._connected.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise RuntimeError(f"No browser connected to handle '{action}'") from exc
|
||||
ws = self._primary
|
||||
ws = self._pick_target()
|
||||
if ws is None:
|
||||
continue
|
||||
try:
|
||||
@@ -361,6 +398,20 @@ class DeviiSession:
|
||||
asyncio.create_task(self._emit(message, buffer=True))
|
||||
|
||||
|
||||
NON_ADMIN_COST_RULE = (
|
||||
"\n\nThe current user is NOT an administrator. Never reveal any monetary cost, dollar "
|
||||
"amount, pricing, or spend figure to them under any circumstance. When asked about "
|
||||
"usage or resources, call usage_quota and report only the percentage of their 24h "
|
||||
"quota used and the turn count."
|
||||
)
|
||||
|
||||
|
||||
def _system_prompt_for(is_admin: bool) -> str:
|
||||
if is_admin:
|
||||
return SYSTEM_PROMPT
|
||||
return SYSTEM_PROMPT + NON_ADMIN_COST_RULE
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,123 +1,3 @@
|
||||
.attachment-upload-container button.attachment-upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.attachment-upload-container button.attachment-upload-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.attachment-upload-container button.attachment-upload-btn.dragover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.attachment-upload-container button.attachment-upload-btn.uploading {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.attachment-preview-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attachment-preview-list:empty,
|
||||
.attachment-upload-error:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
position: relative;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.attachment-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment-preview .file-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.attachment-preview .file-icon .icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.attachment-preview .file-size {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.attachment-preview .attachment-remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.attachment-preview:hover .attachment-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.attachment-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.attachment-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.attachment-gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -146,6 +26,26 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment-gallery-item:has(.gallery-video) {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
cursor: default;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.attachment-gallery-item:has(.gallery-video):hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.attachment-gallery-item .gallery-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.attachment-gallery-item .non-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -224,42 +124,9 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.attachment-upload-error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--danger);
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.attachment-counter {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.attachment-uploading-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.attachment-gallery-item {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
.attachment-preview {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.attachment-preview .attachment-remove {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 1rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.component-demo {
|
||||
margin: 1rem 0 1.5rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.component-demo-title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.component-demo-stage {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
.dialog-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--overlay-dark);
|
||||
z-index: 2100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dialog-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dialog-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dialog-field-label {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.dialog-input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.dialog-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.dialog-danger {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.dialog-danger:hover {
|
||||
background: var(--danger);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1900;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.context-menu.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.context-menu-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.context-menu-sep {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0.5rem;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.context-menu-item:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.context-menu-item.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.context-menu-icon {
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
dp-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
dp-upload {
|
||||
display: block;
|
||||
}
|
||||
|
||||
dp-upload[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dp-upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.dp-upload-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.dp-upload-btn.dragover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.dp-upload-btn.uploading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dp-upload-icon {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dp-upload-count {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dp-upload-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dp-upload-chips:empty {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.dp-upload-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
max-width: 16rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.dp-upload-chip-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dp-upload-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dp-upload-chip-remove:hover {
|
||||
color: var(--danger);
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.dp-upload-error {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.dp-upload-error:empty {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.dp-toast-host {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dp-toast {
|
||||
min-width: 12rem;
|
||||
max-width: 22rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 0.875rem;
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dp-toast.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dp-toast-success {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.dp-toast-error {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
@@ -57,6 +57,45 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.docs-content table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.docs-content th,
|
||||
.docs-content td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.docs-content th {
|
||||
background: var(--bg-card-hover);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.docs-content td {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.docs-content tbody tr:nth-child(even) td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.docs-content td code,
|
||||
.docs-content th code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.code-copy-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
|
||||
@@ -143,6 +143,14 @@
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rendered-content video {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius);
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid var(--border);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.rendered-content a[href^="http"]::after {
|
||||
content: "\u2197";
|
||||
display: inline-block;
|
||||
|
||||
@@ -181,13 +181,14 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages-input-area .attachment-upload-container {
|
||||
.messages-input-area dp-upload {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.messages-input-area .attachment-preview-list {
|
||||
.messages-input-area dp-upload .dp-upload-chips {
|
||||
flex: 0 0 100%;
|
||||
order: -1;
|
||||
margin-top: 0;
|
||||
|
||||
@@ -412,3 +412,159 @@ a.profile-stat-value:hover {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ai-usage-card {
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-card);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.ai-usage-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.ai-usage-title {
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ai-usage-updated {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ai-usage-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ai-quota {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.ai-quota-bar {
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-quota-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.ai-quota-fill.ai-quota-danger {
|
||||
background: var(--danger, #d9534f);
|
||||
}
|
||||
|
||||
.ai-quota-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.ai-quota-pct {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-quota-detail {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-usage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.ai-usage-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.ai-usage-value {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-usage-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.ai-usage-spark-wrap {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.ai-usage-spark-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.ai-usage-spark {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 48px;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.ai-usage-bar {
|
||||
flex: 1 1 auto;
|
||||
min-width: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px 2px 0 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.ai-usage-table {
|
||||
width: 100%;
|
||||
margin-top: var(--space-md);
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ai-usage-table th,
|
||||
.ai-usage-table td {
|
||||
text-align: left;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ai-usage-table th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-usage-table td {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
.pf-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 2rem);
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.pf-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-top: calc(var(--nav-height) + 0.5rem);
|
||||
scroll-margin-top: calc(var(--nav-height) + 1rem);
|
||||
}
|
||||
|
||||
.pf-toolbar .btn {
|
||||
scroll-margin-top: calc(var(--nav-height) + 1rem);
|
||||
}
|
||||
|
||||
.pf-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pf-toolbar .btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.pf-status {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent);
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
.pf-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.pf-tree {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.pf-tree-empty {
|
||||
padding: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pf-tree-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pf-children {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.pf-node.collapsed > .pf-children {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pf-node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pf-node-row:hover {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pf-node-row.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pf-node-row.pf-selected {
|
||||
background: var(--accent-light);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pf-node-row.pf-selected.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pf-node-row.pf-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.pf-node-row.pf-dragover {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -2px;
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.pf-tree.pf-root-drop {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
.pf-root-row {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.pf-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pf-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pf-editor-wrap[hidden],
|
||||
.pf-preview[hidden],
|
||||
.pf-empty[hidden],
|
||||
.pf-current[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pf-current {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: monospace;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 1.25rem;
|
||||
}
|
||||
|
||||
.pf-editor-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pf-editor-wrap .CodeMirror {
|
||||
height: 100%;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pf-preview {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pf-preview img,
|
||||
.pf-preview video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.pf-download {
|
||||
color: var(--accent);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.pf-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pf-empty-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pf-empty-icon {
|
||||
font-size: 2.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.pf-empty-msg {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pf-tree {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,13 @@ class ApiKeyManager {
|
||||
const btn = this.card.querySelector("[data-regen-api-key]");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!window.confirm("Regenerate your API key? The current key stops working immediately.")) return;
|
||||
const ok = await window.app.dialog.confirm({
|
||||
title: "Regenerate API key",
|
||||
message: "Regenerate your API key? The current key stops working immediately.",
|
||||
confirmLabel: "Regenerate",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const result = await Http.postJson("/profile/regenerate-api-key", {});
|
||||
|
||||
@@ -495,7 +495,13 @@ export class ApiTester {
|
||||
|
||||
async send() {
|
||||
if (this.config.destructive || this.config.method === "DELETE") {
|
||||
if (!window.confirm("This performs a real action on your account. Continue?")) return;
|
||||
const ok = await window.app.dialog.confirm({
|
||||
title: "Run request",
|
||||
message: "This performs a real action on your account. Continue?",
|
||||
confirmLabel: "Run",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
const values = this.values();
|
||||
const auth = this.auth();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./components/index.js";
|
||||
import { ModalManager } from "./ModalManager.js";
|
||||
import { FormManager } from "./FormManager.js";
|
||||
import { VoteManager } from "./VoteManager.js";
|
||||
@@ -15,11 +16,16 @@ import { ReactionBar } from "./ReactionBar.js";
|
||||
import { BookmarkManager } from "./BookmarkManager.js";
|
||||
import { PollManager } from "./PollManager.js";
|
||||
import { ApiKeyManager } from "./ApiKeyManager.js";
|
||||
import { UserAiUsage } from "./UserAiUsage.js";
|
||||
import { Tabs } from "./Tabs.js";
|
||||
import { DeviiTerminal } from "./DeviiTerminal.js";
|
||||
|
||||
class Application {
|
||||
constructor() {
|
||||
this.dialog = document.createElement("dp-dialog");
|
||||
this.contextMenu = document.createElement("dp-context-menu");
|
||||
this.toast = document.createElement("dp-toast");
|
||||
document.body.append(this.dialog, this.contextMenu, this.toast);
|
||||
this.modals = new ModalManager();
|
||||
this.forms = new FormManager();
|
||||
this.votes = new VoteManager();
|
||||
@@ -37,6 +43,7 @@ class Application {
|
||||
this.bookmarks = new BookmarkManager();
|
||||
this.polls = new PollManager();
|
||||
this.apiKey = new ApiKeyManager();
|
||||
this.userAiUsage = new UserAiUsage();
|
||||
this.tabs = new Tabs();
|
||||
this.devii = new DeviiTerminal();
|
||||
}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { Toast } from "./Toast.js";
|
||||
|
||||
export class AttachmentUploader {
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
this.attachments = [];
|
||||
this.maxSize = parseInt(form.dataset.maxSize || "10", 10) * 1024 * 1024;
|
||||
this.maxFiles = parseInt(form.dataset.maxFiles || "10", 10);
|
||||
this.allowedTypes = (form.dataset.allowedTypes || "").split(",").map((t) => t.trim()).filter(Boolean);
|
||||
this.container = form.querySelector(".attachment-upload-container");
|
||||
if (!this.container) return;
|
||||
this.uploadedUidsInput = null;
|
||||
this.initZone();
|
||||
}
|
||||
|
||||
initZone() {
|
||||
this.button = document.createElement("button");
|
||||
this.button.type = "button";
|
||||
this.button.className = "attachment-upload-btn";
|
||||
this.button.innerHTML = "📎";
|
||||
this.button.title = "Attach files";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
input.addEventListener("change", () => this.handleFiles(input.files));
|
||||
|
||||
this.button.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
input.click();
|
||||
});
|
||||
|
||||
this.button.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
this.button.classList.add("dragover");
|
||||
});
|
||||
this.button.addEventListener("dragleave", () => {
|
||||
this.button.classList.remove("dragover");
|
||||
});
|
||||
this.button.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
this.button.classList.remove("dragover");
|
||||
this.handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
this.previewList = document.createElement("div");
|
||||
this.previewList.className = "attachment-preview-list";
|
||||
this.errorEl = document.createElement("div");
|
||||
this.errorEl.className = "attachment-upload-error";
|
||||
|
||||
this.container.appendChild(this.button);
|
||||
this.container.appendChild(input);
|
||||
|
||||
this.container.after(this.errorEl);
|
||||
this.container.after(this.previewList);
|
||||
|
||||
this.uploadedUidsInput = document.createElement("input");
|
||||
this.uploadedUidsInput.type = "hidden";
|
||||
this.uploadedUidsInput.name = "attachment_uids";
|
||||
this.form.appendChild(this.uploadedUidsInput);
|
||||
}
|
||||
|
||||
async handleFiles(files) {
|
||||
this.errorEl.textContent = "";
|
||||
const remaining = this.maxFiles - this.attachments.length;
|
||||
if (files.length > remaining) {
|
||||
this.showError(`You can only add ${remaining} more file(s).`);
|
||||
return;
|
||||
}
|
||||
for (const file of files) {
|
||||
if (file.size > this.maxSize) {
|
||||
this.showError(`"${file.name}" exceeds the ${this.form.dataset.maxSize || 10}MB limit.`);
|
||||
continue;
|
||||
}
|
||||
if (this.allowedTypes.length > 0) {
|
||||
const ext = "." + file.name.split(".").pop().toLowerCase();
|
||||
if (!this.allowedTypes.includes(ext)) {
|
||||
this.showError(`"${file.name}" type is not allowed.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await this.uploadFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(file) {
|
||||
this.button.classList.add("uploading");
|
||||
const preview = this.createPreview(file);
|
||||
this.previewList.appendChild(preview);
|
||||
const progressBar = preview.querySelector(".attachment-progress-bar");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/uploads/upload");
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable && progressBar) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
progressBar.style.width = pct + "%";
|
||||
}
|
||||
});
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try { resolve(JSON.parse(xhr.responseText)); }
|
||||
catch { reject(new Error("Invalid response")); }
|
||||
} else {
|
||||
reject(new Error(xhr.responseText || "Upload failed"));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("Network error"));
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
if (result && result.uid) {
|
||||
this.attachments.push(result);
|
||||
this.updateUids();
|
||||
preview.dataset.uid = result.uid;
|
||||
if (result.thumbnail_url) {
|
||||
const img = preview.querySelector("img");
|
||||
if (img) {
|
||||
this.revokePreview(preview);
|
||||
img.src = result.thumbnail_url;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.showError(err.message);
|
||||
this.revokePreview(preview);
|
||||
preview.remove();
|
||||
} finally {
|
||||
this.button.classList.remove("uploading");
|
||||
}
|
||||
}
|
||||
|
||||
createPreview(file) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "attachment-preview";
|
||||
const isImage = file.type && file.type.startsWith("image/");
|
||||
if (isImage) {
|
||||
const img = document.createElement("img");
|
||||
img.src = URL.createObjectURL(file);
|
||||
img.alt = file.name;
|
||||
div.appendChild(img);
|
||||
} else {
|
||||
const iconDiv = document.createElement("div");
|
||||
iconDiv.className = "file-icon";
|
||||
iconDiv.innerHTML = `<span class="icon">📎</span><span>${file.name}</span>`;
|
||||
div.appendChild(iconDiv);
|
||||
}
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "attachment-remove";
|
||||
remove.innerHTML = "×";
|
||||
remove.addEventListener("click", () => {
|
||||
const idx = this.attachments.findIndex((a) => a.uid === div.dataset.uid);
|
||||
if (idx !== -1) {
|
||||
this.attachments.splice(idx, 1);
|
||||
this.updateUids();
|
||||
}
|
||||
this.revokePreview(div);
|
||||
div.remove();
|
||||
});
|
||||
div.appendChild(remove);
|
||||
const progress = document.createElement("div");
|
||||
progress.className = "attachment-progress";
|
||||
progress.innerHTML = '<div class="attachment-progress-bar" style="width:0%"></div>';
|
||||
div.appendChild(progress);
|
||||
return div;
|
||||
}
|
||||
|
||||
revokePreview(preview) {
|
||||
const img = preview.querySelector("img");
|
||||
if (img && img.src.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(img.src);
|
||||
}
|
||||
}
|
||||
|
||||
updateUids() {
|
||||
const uids = this.attachments.map((a) => a.uid);
|
||||
this.uploadedUidsInput.value = uids.join(",");
|
||||
}
|
||||
|
||||
showError(msg) {
|
||||
Toast.flash(this.errorEl, msg, 5000, "");
|
||||
}
|
||||
}
|
||||
|
||||
window.AttachmentUploader = AttachmentUploader;
|
||||
@@ -1,7 +1,6 @@
|
||||
import { contentRenderer } from "./ContentRenderer.js";
|
||||
import { MentionInput } from "./MentionInput.js";
|
||||
import { EmojiPicker } from "./EmojiPicker.js";
|
||||
import { AttachmentUploader } from "./AttachmentUploader.js";
|
||||
|
||||
export class ContentEnhancer {
|
||||
constructor() {
|
||||
@@ -9,7 +8,6 @@ export class ContentEnhancer {
|
||||
this.initEmojiPickers();
|
||||
this.initMentionInputs();
|
||||
this.initImageFallbacks();
|
||||
this.initAttachmentManagers();
|
||||
}
|
||||
|
||||
initContentRenderer() {
|
||||
@@ -61,18 +59,4 @@ export class ContentEnhancer {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initAttachmentManagers() {
|
||||
document.querySelectorAll(".attachment-upload-container").forEach((container) => {
|
||||
if (container.dataset.attachmentInitialized) return;
|
||||
container.dataset.attachmentInitialized = "true";
|
||||
const form = container.closest("form");
|
||||
if (form) {
|
||||
form.dataset.maxSize = container.dataset.maxSize;
|
||||
form.dataset.maxFiles = container.dataset.maxFiles;
|
||||
form.dataset.allowedTypes = container.dataset.allowedTypes || "";
|
||||
new AttachmentUploader(form);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export class ContentRenderer {
|
||||
constructor() {
|
||||
this.emojiMap = this.buildEmojiMap();
|
||||
this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i;
|
||||
this.videoExtRe = /\.(mp4|webm|ogg|ogv|mov|m4v)(\?.*)?$/i;
|
||||
this.youtubeRe = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||
}
|
||||
|
||||
@@ -166,11 +167,19 @@ export class ContentRenderer {
|
||||
return img;
|
||||
}
|
||||
|
||||
if (this.videoExtRe.test(url)) {
|
||||
const video = document.createElement("video");
|
||||
video.src = url;
|
||||
video.controls = true;
|
||||
video.preload = "metadata";
|
||||
return video;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
walkNodes(root, callback) {
|
||||
const skipTags = new Set(["CODE", "PRE", "A", "IFRAME", "IMG", "SCRIPT", "STYLE"]);
|
||||
const skipTags = new Set(["CODE", "PRE", "A", "IFRAME", "IMG", "VIDEO", "SCRIPT", "STYLE"]);
|
||||
const iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
while ((node = iter.nextNode())) {
|
||||
|
||||
@@ -7,6 +7,7 @@ export class DeviiTerminal {
|
||||
constructor() {
|
||||
this.element = null;
|
||||
this.avatar = null;
|
||||
this.bindEscapeTrigger();
|
||||
this.ready = this.init();
|
||||
}
|
||||
|
||||
@@ -42,6 +43,32 @@ export class DeviiTerminal {
|
||||
}
|
||||
}
|
||||
|
||||
bindEscapeTrigger() {
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key !== "Escape" || event.repeat) return;
|
||||
if (this._canTriggerOnEscape()) this.open();
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
_canTriggerOnEscape() {
|
||||
if (this.element && this.element.state !== "closed") return false;
|
||||
if (document.querySelector(".modal-overlay.visible")) return false;
|
||||
if (document.querySelector(".dialog-overlay.visible")) return false;
|
||||
if (document.querySelector(".context-menu.visible")) return false;
|
||||
const mobilePanel = document.getElementById("mobile-panel");
|
||||
if (mobilePanel && mobilePanel.classList.contains("open")) return false;
|
||||
const active = document.activeElement;
|
||||
if (active) {
|
||||
const editable = ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName);
|
||||
if (editable || active.isContentEditable) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.ready;
|
||||
if (this.element) this.element.open();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { LANGUAGE_MODES } from "./codemirrorModes.js";
|
||||
|
||||
class GistEditor {
|
||||
constructor(textareaId, langSelectId) {
|
||||
this.editor = null;
|
||||
@@ -38,23 +40,8 @@ class GistEditor {
|
||||
const langSelect = document.getElementById(this.langSelectId);
|
||||
if (langSelect) {
|
||||
langSelect.addEventListener("change", () => {
|
||||
let mode = langSelect.value;
|
||||
const modeMap = {
|
||||
"c": "text/x-csrc",
|
||||
"cpp": "text/x-c++src",
|
||||
"java": "text/x-java",
|
||||
"bash": "text/x-sh",
|
||||
"ruby": "text/x-ruby",
|
||||
"php": "text/x-php",
|
||||
"perl": "text/x-perl",
|
||||
"haskell": "text/x-haskell",
|
||||
"lua": "text/x-lua",
|
||||
"elixir": "text/x-elixir",
|
||||
"dart": "text/x-dart",
|
||||
"r": "text/x-rsrc",
|
||||
"scala": "text/x-scala",
|
||||
};
|
||||
this.editor.setOption("mode", modeMap[mode] || mode);
|
||||
const mode = langSelect.value;
|
||||
this.editor.setOption("mode", LANGUAGE_MODES[mode] || mode);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,12 +46,32 @@ export class ModalManager {
|
||||
}
|
||||
|
||||
initConfirmations() {
|
||||
document.querySelectorAll("[data-confirm]").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
if (!confirm(btn.dataset.confirm)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
document.addEventListener("click", (e) => {
|
||||
const el = e.target.closest("[data-confirm]");
|
||||
if (!el) return;
|
||||
if (el.dataset.confirmed === "1") {
|
||||
delete el.dataset.confirmed;
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const proceed = () => {
|
||||
el.dataset.confirmed = "1";
|
||||
el.click();
|
||||
};
|
||||
const dialog = window.app && window.app.dialog;
|
||||
if (!dialog) {
|
||||
if (confirm(el.dataset.confirm)) proceed();
|
||||
return;
|
||||
}
|
||||
dialog.confirm({
|
||||
message: el.dataset.confirm,
|
||||
title: el.dataset.confirmTitle || "Please confirm",
|
||||
confirmLabel: el.dataset.confirmLabel,
|
||||
danger: el.dataset.confirmDanger !== undefined,
|
||||
}).then((ok) => {
|
||||
if (ok) proceed();
|
||||
});
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
import { Toast } from "./Toast.js";
|
||||
import { modeForFilename } from "./codemirrorModes.js";
|
||||
|
||||
export class ProjectFiles {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.slug = root.dataset.slug;
|
||||
this.isOwner = root.dataset.owner === "1";
|
||||
this.base = `/projects/${this.slug}/files`;
|
||||
this.files = [];
|
||||
this.editor = null;
|
||||
this.currentPath = null;
|
||||
this.currentDir = "";
|
||||
this.selected = new Set();
|
||||
this.order = [];
|
||||
this.anchor = null;
|
||||
this.dragPaths = null;
|
||||
this.tree = root.querySelector("#pf-tree");
|
||||
this.preview = root.querySelector("#pf-preview");
|
||||
this.empty = root.querySelector("#pf-empty");
|
||||
this.emptyMsg = root.querySelector(".pf-empty-msg");
|
||||
this.currentLabel = root.querySelector("#pf-current");
|
||||
this.editorWrap = root.querySelector("#pf-editor-wrap");
|
||||
this.uploadWidget = root.querySelector("#pf-upload-widget");
|
||||
this.status = root.querySelector("#pf-status");
|
||||
}
|
||||
|
||||
notify(message) {
|
||||
if (this.status) Toast.flash(this.status, message, 3000, "");
|
||||
}
|
||||
|
||||
init() {
|
||||
const data = this.root.querySelector("#pf-data");
|
||||
this.files = data ? JSON.parse(data.textContent || "[]") : [];
|
||||
this.initEditor();
|
||||
this.bindToolbar();
|
||||
this.bindTree();
|
||||
this.renderTree();
|
||||
this.openDefault();
|
||||
}
|
||||
|
||||
firstFilePath() {
|
||||
for (const path of this.order) {
|
||||
const node = this.files.find((file) => file.path === path);
|
||||
if (node && node.type === "file") return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
openDefault() {
|
||||
const files = this.files.filter((file) => file.type === "file");
|
||||
const readme = files.find((file) => file.name.toLowerCase() === "readme.md")
|
||||
|| files.find((file) => file.name.toLowerCase().startsWith("readme."));
|
||||
const path = readme ? readme.path : this.firstFilePath();
|
||||
if (!path) {
|
||||
this.showEmpty(this.isOwner ? "Create a file to get started." : "No files yet.");
|
||||
return;
|
||||
}
|
||||
this.selected = new Set([path]);
|
||||
this.anchor = path;
|
||||
this.applySelection();
|
||||
this.markActive(path);
|
||||
this.currentDir = this.dirOf(path);
|
||||
this.openFile(path);
|
||||
}
|
||||
|
||||
markActive(path) {
|
||||
this.root.querySelectorAll(".pf-node-row.active").forEach((el) => el.classList.remove("active"));
|
||||
const row = this.root.querySelector(`.pf-node-row[data-path="${CSS.escape(path)}"]`);
|
||||
if (row) row.classList.add("active");
|
||||
}
|
||||
|
||||
initEditor() {
|
||||
if (typeof CodeMirror === "undefined") return;
|
||||
const textarea = this.root.querySelector("#project-file-editor");
|
||||
if (!textarea) return;
|
||||
this.editor = CodeMirror.fromTextArea(textarea, {
|
||||
lineNumbers: true,
|
||||
mode: "plaintext",
|
||||
theme: "monokai",
|
||||
indentUnit: 4,
|
||||
tabSize: 4,
|
||||
lineWrapping: true,
|
||||
readOnly: !this.isOwner,
|
||||
extraKeys: { "Ctrl-S": () => this.save() },
|
||||
});
|
||||
this.editor.setSize(null, "100%");
|
||||
}
|
||||
|
||||
bindToolbar() {
|
||||
const on = (id, handler) => {
|
||||
const el = this.root.querySelector(id);
|
||||
if (el) el.addEventListener("click", (e) => { e.preventDefault(); handler(); });
|
||||
};
|
||||
on("#pf-new-file", () => this.newFile());
|
||||
on("#pf-new-folder", () => this.newFolder());
|
||||
on("#pf-upload", () => this.uploadTo(this.currentDir));
|
||||
on("#pf-save", () => this.save());
|
||||
on("#pf-rename", () => this.rename());
|
||||
on("#pf-delete", () => this.remove());
|
||||
if (this.uploadWidget) {
|
||||
this.uploadWidget.addEventListener("dp-upload:done", () => this.refresh());
|
||||
this.uploadWidget.addEventListener("dp-upload:error", (e) => this.notify(e.detail.message));
|
||||
}
|
||||
}
|
||||
|
||||
bindTree() {
|
||||
this.tree.addEventListener("click", (e) => {
|
||||
if (e.target === this.tree) {
|
||||
this.selected.clear();
|
||||
this.applySelection();
|
||||
}
|
||||
});
|
||||
if (!this.isOwner) return;
|
||||
if (window.app && window.app.contextMenu) {
|
||||
window.app.contextMenu.attach(this.tree, (e) => this.buildMenu(e));
|
||||
}
|
||||
this.tree.addEventListener("dragover", (e) => {
|
||||
if (!this.dragPaths) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
this.tree.classList.add("pf-root-drop");
|
||||
});
|
||||
this.tree.addEventListener("dragleave", (e) => {
|
||||
if (e.target === this.tree) this.tree.classList.remove("pf-root-drop");
|
||||
});
|
||||
this.tree.addEventListener("drop", (e) => {
|
||||
if (!this.dragPaths) return;
|
||||
e.preventDefault();
|
||||
this.tree.classList.remove("pf-root-drop");
|
||||
this.onDrop("");
|
||||
});
|
||||
}
|
||||
|
||||
dirOf(path) {
|
||||
return path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
||||
}
|
||||
|
||||
basename(path) {
|
||||
return path.split("/").pop();
|
||||
}
|
||||
|
||||
joinDir(dir, name) {
|
||||
const clean = name.replace(/^\/+/, "");
|
||||
return dir ? `${dir}/${clean}` : clean;
|
||||
}
|
||||
|
||||
buildTree() {
|
||||
const nodes = new Map();
|
||||
this.files.forEach((file) => nodes.set(file.path, { ...file, children: [] }));
|
||||
const roots = [];
|
||||
this.files.forEach((file) => {
|
||||
const node = nodes.get(file.path);
|
||||
const parent = file.path.includes("/") ? nodes.get(file.path.slice(0, file.path.lastIndexOf("/"))) : null;
|
||||
(parent ? parent.children : roots).push(node);
|
||||
});
|
||||
const sort = (list) => {
|
||||
list.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
|
||||
list.forEach((node) => sort(node.children));
|
||||
};
|
||||
sort(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
renderTree() {
|
||||
this.order = [];
|
||||
const known = new Set(this.files.map((f) => f.path));
|
||||
this.selected = new Set([...this.selected].filter((p) => known.has(p)));
|
||||
this.tree.textContent = "";
|
||||
this.tree.appendChild(this.renderRootRow());
|
||||
const roots = this.buildTree();
|
||||
if (!roots.length) {
|
||||
const hint = document.createElement("div");
|
||||
hint.className = "pf-tree-empty";
|
||||
hint.textContent = this.isOwner ? "Empty project. Create a file or folder." : "No files yet.";
|
||||
this.tree.appendChild(hint);
|
||||
return;
|
||||
}
|
||||
this.tree.appendChild(this.renderNodes(roots));
|
||||
this.applySelection();
|
||||
}
|
||||
|
||||
renderRootRow() {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "pf-node-row pf-root-row";
|
||||
row.dataset.path = "";
|
||||
row.dataset.type = "dir";
|
||||
row.draggable = false;
|
||||
row.innerHTML = '<span class="pf-icon">📦</span><span class="pf-label">project root</span>';
|
||||
row.addEventListener("click", () => {
|
||||
this.selected.clear();
|
||||
this.applySelection();
|
||||
this.root.querySelectorAll(".pf-node-row.active").forEach((el) => el.classList.remove("active"));
|
||||
row.classList.add("active");
|
||||
this.currentPath = null;
|
||||
this.currentDir = "";
|
||||
this.showEmpty("Project root");
|
||||
});
|
||||
if (this.isOwner) {
|
||||
row.addEventListener("dragover", (e) => {
|
||||
if (!this.dragPaths) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
row.classList.add("pf-dragover");
|
||||
});
|
||||
row.addEventListener("dragleave", () => row.classList.remove("pf-dragover"));
|
||||
row.addEventListener("drop", (e) => {
|
||||
if (!this.dragPaths) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
row.classList.remove("pf-dragover");
|
||||
this.onDrop("");
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
renderNodes(list) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "pf-tree-list";
|
||||
list.forEach((node) => {
|
||||
this.order.push(node.path);
|
||||
const li = document.createElement("li");
|
||||
li.className = `pf-node pf-${node.type}`;
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "pf-node-row";
|
||||
row.dataset.path = node.path;
|
||||
row.dataset.type = node.type;
|
||||
row.draggable = this.isOwner;
|
||||
row.innerHTML = `<span class="pf-icon">${node.type === "dir" ? "📁" : "📄"}</span><span class="pf-label"></span>`;
|
||||
row.querySelector(".pf-label").textContent = node.name;
|
||||
row.addEventListener("click", (e) => this.onRowClick(node, li, e));
|
||||
if (this.isOwner) this.bindDrag(row, node);
|
||||
li.appendChild(row);
|
||||
if (node.type === "dir") {
|
||||
const child = this.renderNodes(node.children);
|
||||
child.classList.add("pf-children");
|
||||
li.appendChild(child);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
});
|
||||
return ul;
|
||||
}
|
||||
|
||||
bindDrag(row, node) {
|
||||
row.addEventListener("dragstart", (e) => {
|
||||
const paths = this.selected.has(node.path) && this.selected.size ? Array.from(this.selected) : [node.path];
|
||||
this.dragPaths = paths;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", paths.join("\n"));
|
||||
row.classList.add("pf-dragging");
|
||||
});
|
||||
row.addEventListener("dragend", () => {
|
||||
row.classList.remove("pf-dragging");
|
||||
this.dragPaths = null;
|
||||
this.root.querySelectorAll(".pf-dragover").forEach((el) => el.classList.remove("pf-dragover"));
|
||||
this.tree.classList.remove("pf-root-drop");
|
||||
});
|
||||
if (node.type !== "dir") return;
|
||||
row.addEventListener("dragover", (e) => {
|
||||
if (!this.dragPaths) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
row.classList.add("pf-dragover");
|
||||
});
|
||||
row.addEventListener("dragleave", () => row.classList.remove("pf-dragover"));
|
||||
row.addEventListener("drop", (e) => {
|
||||
if (!this.dragPaths) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
row.classList.remove("pf-dragover");
|
||||
this.onDrop(node.path);
|
||||
});
|
||||
}
|
||||
|
||||
onDrop(targetDir) {
|
||||
const paths = this.dragPaths || [];
|
||||
this.dragPaths = null;
|
||||
if (paths.length) this.moveInto(targetDir, paths);
|
||||
}
|
||||
|
||||
onRowClick(node, li, e) {
|
||||
if (e.shiftKey && this.anchor) {
|
||||
this.selectRange(this.anchor, node.path);
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (this.selected.has(node.path)) this.selected.delete(node.path);
|
||||
else this.selected.add(node.path);
|
||||
this.anchor = node.path;
|
||||
this.applySelection();
|
||||
return;
|
||||
}
|
||||
this.selected = new Set([node.path]);
|
||||
this.anchor = node.path;
|
||||
this.applySelection();
|
||||
this.activate(node, li);
|
||||
}
|
||||
|
||||
activate(node, li) {
|
||||
this.root.querySelectorAll(".pf-node-row.active").forEach((el) => el.classList.remove("active"));
|
||||
const row = li.querySelector(".pf-node-row");
|
||||
if (row) row.classList.add("active");
|
||||
if (node.type === "dir") {
|
||||
li.classList.toggle("collapsed");
|
||||
this.currentDir = node.path;
|
||||
return;
|
||||
}
|
||||
this.currentDir = this.dirOf(node.path);
|
||||
this.openFile(node.path);
|
||||
}
|
||||
|
||||
selectRange(fromPath, toPath) {
|
||||
const i = this.order.indexOf(fromPath);
|
||||
const j = this.order.indexOf(toPath);
|
||||
if (i < 0 || j < 0) return;
|
||||
const [a, b] = i <= j ? [i, j] : [j, i];
|
||||
this.selected = new Set(this.order.slice(a, b + 1));
|
||||
this.applySelection();
|
||||
}
|
||||
|
||||
applySelection() {
|
||||
this.root.querySelectorAll(".pf-node-row").forEach((row) => {
|
||||
row.classList.toggle("pf-selected", this.selected.has(row.dataset.path));
|
||||
});
|
||||
if (this.status && this.selected.size > 1) Toast.flash(this.status, `${this.selected.size} selected`, 1500, "");
|
||||
}
|
||||
|
||||
buildMenu(e) {
|
||||
if (!this.isOwner) return [];
|
||||
const rowEl = e.target.closest(".pf-node-row");
|
||||
if (!rowEl) {
|
||||
return [
|
||||
{ label: "New file", icon: "📄", onSelect: () => this.newFile("") },
|
||||
{ label: "New folder", icon: "📁", onSelect: () => this.newFolder("") },
|
||||
{ label: "Upload here", icon: "⬆", onSelect: () => this.uploadTo("") },
|
||||
];
|
||||
}
|
||||
const path = rowEl.dataset.path;
|
||||
const type = rowEl.dataset.type;
|
||||
if (this.selected.size > 1 && this.selected.has(path)) {
|
||||
const count = this.selected.size;
|
||||
return [
|
||||
{ label: `Move ${count} items to...`, icon: "➡", onSelect: () => this.promptMove(Array.from(this.selected)) },
|
||||
{ separator: true },
|
||||
{ label: `Delete ${count} items`, icon: "🗑", danger: true, onSelect: () => this.remove(path) },
|
||||
];
|
||||
}
|
||||
const dir = type === "dir" ? path : this.dirOf(path);
|
||||
const items = [];
|
||||
if (type === "file") {
|
||||
items.push({ label: "Open", icon: "📄", onSelect: () => this.openFile(path) });
|
||||
} else {
|
||||
items.push({ label: "New file here", icon: "📄", onSelect: () => this.newFile(dir) });
|
||||
items.push({ label: "New folder here", icon: "📁", onSelect: () => this.newFolder(dir) });
|
||||
items.push({ label: "Upload here", icon: "⬆", onSelect: () => this.uploadTo(dir) });
|
||||
}
|
||||
items.push({ separator: true });
|
||||
items.push({ label: "Rename / move", icon: "✏", onSelect: () => this.rename(path) });
|
||||
items.push({ label: "Delete", icon: "🗑", danger: true, onSelect: () => this.remove(path) });
|
||||
return items;
|
||||
}
|
||||
|
||||
async openFile(path) {
|
||||
try {
|
||||
const res = await fetch(`${this.base}/raw?path=${encodeURIComponent(path)}`, { headers: { "Accept": "application/json" } });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ? data.error.message : "Could not open file");
|
||||
this.currentPath = path;
|
||||
this.currentLabel.textContent = path;
|
||||
this.currentLabel.hidden = false;
|
||||
if (data.is_binary) {
|
||||
this.showPreview(data);
|
||||
} else {
|
||||
this.showEditor(path, data.content || "");
|
||||
}
|
||||
} catch (err) {
|
||||
this.notify(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
showEditor(path, content) {
|
||||
this.empty.hidden = true;
|
||||
this.preview.hidden = true;
|
||||
this.editorWrap.hidden = false;
|
||||
if (this.editor) {
|
||||
this.editor.setOption("mode", modeForFilename(path));
|
||||
this.editor.setValue(content);
|
||||
setTimeout(() => this.editor.refresh(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
showPreview(data) {
|
||||
this.empty.hidden = true;
|
||||
this.editorWrap.hidden = true;
|
||||
this.preview.hidden = false;
|
||||
this.preview.textContent = "";
|
||||
const mime = data.mime_type || "";
|
||||
if (mime.startsWith("image/")) {
|
||||
const img = document.createElement("img");
|
||||
img.src = data.url;
|
||||
img.alt = data.name;
|
||||
this.preview.appendChild(img);
|
||||
} else if (mime.startsWith("video/")) {
|
||||
const video = document.createElement("video");
|
||||
video.src = data.url;
|
||||
video.controls = true;
|
||||
video.preload = "metadata";
|
||||
this.preview.appendChild(video);
|
||||
} else {
|
||||
const link = document.createElement("a");
|
||||
link.href = data.url;
|
||||
link.textContent = `Download ${data.name}`;
|
||||
link.className = "pf-download";
|
||||
link.setAttribute("download", data.name);
|
||||
this.preview.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
showEmpty(message) {
|
||||
this.editorWrap.hidden = true;
|
||||
this.preview.hidden = true;
|
||||
this.empty.hidden = false;
|
||||
if (this.emptyMsg) this.emptyMsg.textContent = message || "Select a file to view or edit.";
|
||||
this.currentLabel.textContent = "";
|
||||
this.currentLabel.hidden = true;
|
||||
}
|
||||
|
||||
async post(action, body) {
|
||||
const res = await fetch(`${this.base}/${action}`, {
|
||||
method: "POST",
|
||||
headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(body).toString(),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.error ? data.error.message : "Operation failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async refresh(openPath) {
|
||||
const res = await fetch(this.base, { headers: { "Accept": "application/json" } });
|
||||
const data = await res.json();
|
||||
this.files = data.files || [];
|
||||
this.renderTree();
|
||||
if (openPath) this.openFile(openPath);
|
||||
}
|
||||
|
||||
async promptInput(options) {
|
||||
const dialog = window.app && window.app.dialog;
|
||||
if (dialog) return dialog.prompt(options);
|
||||
return window.prompt(options.message || options.label || options.title || "", options.value || "");
|
||||
}
|
||||
|
||||
async confirmAction(options) {
|
||||
const dialog = window.app && window.app.dialog;
|
||||
if (dialog) return dialog.confirm(options);
|
||||
return window.confirm(options.message);
|
||||
}
|
||||
|
||||
async newFile(baseDir) {
|
||||
const dir = baseDir !== undefined ? baseDir : this.currentDir;
|
||||
const name = await this.promptInput({
|
||||
title: "New file",
|
||||
label: dir ? `Inside ${dir}/` : "In project root",
|
||||
placeholder: "main.py",
|
||||
confirmLabel: "Create",
|
||||
});
|
||||
if (!name || !name.trim()) return;
|
||||
const path = this.joinDir(dir, name.trim());
|
||||
try {
|
||||
await this.post("write", { path, content: "" });
|
||||
await this.refresh(path);
|
||||
} catch (err) { this.notify(err.message); }
|
||||
}
|
||||
|
||||
async newFolder(baseDir) {
|
||||
const dir = baseDir !== undefined ? baseDir : this.currentDir;
|
||||
const name = await this.promptInput({
|
||||
title: "New folder",
|
||||
label: dir ? `Inside ${dir}/` : "In project root",
|
||||
placeholder: "src",
|
||||
confirmLabel: "Create",
|
||||
});
|
||||
if (!name || !name.trim()) return;
|
||||
const path = this.joinDir(dir, name.trim());
|
||||
try {
|
||||
await this.post("mkdir", { path });
|
||||
this.currentDir = path;
|
||||
await this.refresh();
|
||||
} catch (err) { this.notify(err.message); }
|
||||
}
|
||||
|
||||
uploadTo(dir) {
|
||||
if (!this.uploadWidget) return;
|
||||
this.uploadWidget.extraFields = { path: dir };
|
||||
this.uploadWidget.open();
|
||||
}
|
||||
|
||||
async save() {
|
||||
if (!this.currentPath || !this.editor) return;
|
||||
try {
|
||||
await this.post("write", { path: this.currentPath, content: this.editor.getValue() });
|
||||
this.notify("Saved");
|
||||
} catch (err) { this.notify(err.message); }
|
||||
}
|
||||
|
||||
async rename(target) {
|
||||
const from = target || this.currentPath || this.currentDir;
|
||||
if (!from) { this.notify("Select a file or folder first"); return; }
|
||||
const to = await this.promptInput({
|
||||
title: "Rename / move",
|
||||
label: "New path",
|
||||
value: from,
|
||||
confirmLabel: "Save",
|
||||
});
|
||||
if (!to || !to.trim() || to.trim() === from) return;
|
||||
try {
|
||||
await this.post("move", { from_path: from, to_path: to.trim() });
|
||||
await this.refresh(this.currentPath === from ? to.trim() : null);
|
||||
} catch (err) { this.notify(err.message); }
|
||||
}
|
||||
|
||||
async remove(target) {
|
||||
const multi = this.selected.size > 1 && (!target || this.selected.has(target));
|
||||
const paths = multi ? Array.from(this.selected) : [target || this.currentPath || this.currentDir].filter(Boolean);
|
||||
if (!paths.length) { this.notify("Select a file or folder first"); return; }
|
||||
const message = paths.length > 1
|
||||
? `Delete ${paths.length} items? This cannot be undone.`
|
||||
: `Delete "${paths[0]}"? This cannot be undone.`;
|
||||
const ok = await this.confirmAction({ title: "Delete", message, danger: true, confirmLabel: "Delete" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
for (const path of paths) await this.post("delete", { path });
|
||||
this.selected.clear();
|
||||
this.currentPath = null;
|
||||
await this.refresh();
|
||||
this.openDefault();
|
||||
} catch (err) { this.notify(err.message); }
|
||||
}
|
||||
|
||||
async promptMove(paths) {
|
||||
const to = await this.promptInput({
|
||||
title: `Move ${paths.length} items`,
|
||||
label: "Target directory (empty for project root)",
|
||||
placeholder: "src",
|
||||
confirmLabel: "Move",
|
||||
});
|
||||
if (to === null || to === undefined) return;
|
||||
await this.moveInto(to.trim().replace(/^\/+|\/+$/g, ""), paths);
|
||||
}
|
||||
|
||||
async moveInto(targetDir, paths) {
|
||||
const valid = paths.filter((path) => {
|
||||
if (targetDir === path || targetDir.startsWith(`${path}/`)) return false;
|
||||
if (targetDir === this.dirOf(path)) return false;
|
||||
return true;
|
||||
});
|
||||
if (!valid.length) return;
|
||||
try {
|
||||
for (const path of valid) {
|
||||
const to = targetDir ? `${targetDir}/${this.basename(path)}` : this.basename(path);
|
||||
await this.post("move", { from_path: path, to_path: to });
|
||||
}
|
||||
this.selected.clear();
|
||||
await this.refresh();
|
||||
} catch (err) { this.notify(err.message); }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -62,7 +62,10 @@ export class PushManager {
|
||||
} catch (error) {
|
||||
console.error("Error registering push notifications:", error);
|
||||
if (!silent) {
|
||||
alert("Enabling push notifications failed. Please check your browser settings and try again.\n\n" + error);
|
||||
window.app.dialog.alert({
|
||||
title: "Push notifications",
|
||||
message: "Enabling push notifications failed. Please check your browser settings and try again.\n\n" + error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,6 @@ class ServiceMonitor {
|
||||
async runAction(button) {
|
||||
const action = button.dataset.action;
|
||||
const name = button.dataset.service;
|
||||
const confirmation = button.dataset.confirm;
|
||||
if (confirmation && !window.confirm(confirmation)) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await Http.sendForm(`/admin/services/${name}/${action}`, {});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
class UserAiUsage {
|
||||
constructor() {
|
||||
this.card = document.querySelector("[data-ai-usage]");
|
||||
if (!this.card) return;
|
||||
this.uid = this.card.dataset.uid || "";
|
||||
this.body = this.card.querySelector("[data-ai-usage-body]");
|
||||
this.updated = this.card.querySelector("[data-ai-usage-updated]");
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
if (!this.uid) return;
|
||||
try {
|
||||
const data = await Http.getJson(`/admin/users/${this.uid}/ai-usage?hours=24`);
|
||||
this.render(data);
|
||||
} catch {
|
||||
this.body.innerHTML = '<p class="ai-usage-empty">Usage unavailable.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
render(data) {
|
||||
if (!data || !data.requests) {
|
||||
this.body.innerHTML = '<p class="ai-usage-empty">No AI usage in the last 24 hours.</p>';
|
||||
if (this.updated) this.updated.textContent = "";
|
||||
return;
|
||||
}
|
||||
const cost = data.cost || {};
|
||||
const tokens = data.tokens || {};
|
||||
const latency = data.latency || {};
|
||||
const cards = [
|
||||
["Requests", String(data.requests)],
|
||||
["Success", `${data.success_pct}%`],
|
||||
["Errors", `${data.error_pct}%`],
|
||||
["Cost 24h", this.money(cost.window_usd)],
|
||||
["Projected 30d", this.money(cost.projected_30d_usd)],
|
||||
["Cost / request", this.money(cost.per_request_usd)],
|
||||
["Cost / hour", this.money(cost.per_hour_usd)],
|
||||
["Total tokens", this.count(tokens.total)],
|
||||
["Prompt tokens", this.count(tokens.prompt)],
|
||||
["Completion tokens", this.count(tokens.completion)],
|
||||
["Avg latency", `${Math.round(latency.avg_ms || 0)} ms`],
|
||||
["Avg tokens/s", String(latency.avg_tps || 0)],
|
||||
];
|
||||
const grid = cards
|
||||
.map(([label, value]) => `<div class="ai-usage-stat"><span class="ai-usage-value">${value}</span><span class="ai-usage-label">${label}</span></div>`)
|
||||
.join("");
|
||||
const models = (data.by_model || [])
|
||||
.map((m) => `<tr><td>${this.escape(m.key)}</td><td>${m.requests}</td><td>${this.count(m.total_tokens)}</td><td>${this.money(m.cost_usd)}</td></tr>`)
|
||||
.join("");
|
||||
const modelTable = models
|
||||
? `<table class="ai-usage-table"><thead><tr><th>Model</th><th>Requests</th><th>Tokens</th><th>Cost</th></tr></thead><tbody>${models}</tbody></table>`
|
||||
: "";
|
||||
this.body.innerHTML = `<div class="ai-usage-grid">${grid}</div>${this.spark(data.hourly)}${modelTable}`;
|
||||
if (this.updated) {
|
||||
this.updated.textContent = `last used ${this.when(data.last_used)}`;
|
||||
}
|
||||
}
|
||||
|
||||
spark(hourly) {
|
||||
const rows = hourly || [];
|
||||
if (!rows.length) return "";
|
||||
const peak = Math.max(...rows.map((h) => h.cost_usd || 0)) || 1;
|
||||
const bars = rows
|
||||
.map((h) => {
|
||||
const height = Math.max(2, Math.round((h.cost_usd || 0) / peak * 100));
|
||||
return `<span class="ai-usage-bar" style="height:${height}%" title="${h.hour}: ${this.money(h.cost_usd)} / ${h.requests} req"></span>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="ai-usage-spark-wrap"><span class="ai-usage-spark-label">Cost per hour</span><div class="ai-usage-spark">${bars}</div></div>`;
|
||||
}
|
||||
|
||||
money(value) {
|
||||
return `$${Number(value || 0).toFixed(4)}`;
|
||||
}
|
||||
|
||||
count(value) {
|
||||
return Number(value || 0).toLocaleString();
|
||||
}
|
||||
|
||||
when(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
escape(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = String(text == null ? "" : text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
window.UserAiUsage = UserAiUsage;
|
||||
export { UserAiUsage };
|
||||
@@ -0,0 +1,57 @@
|
||||
export const LANGUAGE_MODES = {
|
||||
"c": "text/x-csrc",
|
||||
"cpp": "text/x-c++src",
|
||||
"java": "text/x-java",
|
||||
"bash": "text/x-sh",
|
||||
"ruby": "text/x-ruby",
|
||||
"php": "text/x-php",
|
||||
"perl": "text/x-perl",
|
||||
"haskell": "text/x-haskell",
|
||||
"lua": "text/x-lua",
|
||||
"elixir": "text/x-elixir",
|
||||
"dart": "text/x-dart",
|
||||
"r": "text/x-rsrc",
|
||||
"scala": "text/x-scala",
|
||||
};
|
||||
|
||||
export const EXTENSION_MODES = {
|
||||
"py": "python",
|
||||
"js": "javascript", "mjs": "javascript", "cjs": "javascript", "jsx": "javascript",
|
||||
"ts": "text/typescript", "tsx": "text/typescript",
|
||||
"json": "application/json", "jsonc": "application/json",
|
||||
"c": "text/x-csrc", "h": "text/x-csrc",
|
||||
"cpp": "text/x-c++src", "cc": "text/x-c++src", "hpp": "text/x-c++src", "hh": "text/x-c++src",
|
||||
"cs": "text/x-csharp",
|
||||
"java": "text/x-java",
|
||||
"go": "go",
|
||||
"rb": "text/x-ruby",
|
||||
"php": "text/x-php",
|
||||
"pl": "text/x-perl", "pm": "text/x-perl",
|
||||
"lua": "text/x-lua",
|
||||
"r": "text/x-rsrc",
|
||||
"dart": "text/x-dart",
|
||||
"swift": "swift",
|
||||
"hs": "text/x-haskell",
|
||||
"sql": "sql",
|
||||
"sh": "text/x-sh", "bash": "text/x-sh", "zsh": "text/x-sh",
|
||||
"yaml": "yaml", "yml": "yaml",
|
||||
"md": "markdown", "markdown": "markdown",
|
||||
"html": "htmlmixed", "htm": "htmlmixed",
|
||||
"xml": "xml", "svg": "xml",
|
||||
"css": "css", "scss": "css", "sass": "css", "less": "css",
|
||||
};
|
||||
|
||||
const FILENAME_MODES = {
|
||||
"dockerfile": "text/x-sh",
|
||||
"makefile": "text/x-sh",
|
||||
".gitignore": "text/plain",
|
||||
".env": "text/plain",
|
||||
};
|
||||
|
||||
export function modeForFilename(name) {
|
||||
const lower = (name || "").toLowerCase();
|
||||
if (FILENAME_MODES[lower]) return FILENAME_MODES[lower];
|
||||
const dot = lower.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? lower.slice(dot + 1) : "";
|
||||
return EXTENSION_MODES[ext] || "plaintext";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
import { Avatar } from "../Avatar.js";
|
||||
|
||||
export class AppAvatar extends Component {
|
||||
static get observedAttributes() {
|
||||
return ["username", "size"];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
if (this.isConnected) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const username = this.attr("username");
|
||||
const size = this.intAttr("size", 24);
|
||||
this.textContent = "";
|
||||
if (!username) {
|
||||
return;
|
||||
}
|
||||
this.appendChild(Avatar.imgElement(username, size));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-avatar", AppAvatar);
|
||||
@@ -0,0 +1,31 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
import { CodeBlock } from "../CodeBlock.js";
|
||||
|
||||
export class AppCode extends Component {
|
||||
connectedCallback() {
|
||||
if (this._enhanced) {
|
||||
return;
|
||||
}
|
||||
this._enhanced = true;
|
||||
let pre = this.querySelector("pre");
|
||||
if (!pre) {
|
||||
const source = this.textContent.replace(/^\n+|\s+$/g, "");
|
||||
this.textContent = "";
|
||||
pre = document.createElement("pre");
|
||||
const code = document.createElement("code");
|
||||
code.textContent = source;
|
||||
pre.appendChild(code);
|
||||
this.appendChild(pre);
|
||||
}
|
||||
const code = pre.querySelector("code");
|
||||
const language = this.attr("language");
|
||||
if (code && language) {
|
||||
code.className = `language-${language}`;
|
||||
}
|
||||
CodeBlock.enhance(pre, { lineNumbers: this.boolAttr("line-numbers") });
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-code", AppCode);
|
||||
@@ -0,0 +1,24 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
import { contentRenderer } from "../ContentRenderer.js";
|
||||
|
||||
export class AppContent extends Component {
|
||||
connectedCallback() {
|
||||
if (this._rendered) {
|
||||
return;
|
||||
}
|
||||
if (typeof DOMPurify === "undefined" || typeof marked === "undefined") {
|
||||
window.addEventListener("load", () => this.connectedCallback(), { once: true });
|
||||
return;
|
||||
}
|
||||
this._rendered = true;
|
||||
this.classList.add("rendered-content");
|
||||
contentRenderer.applyTo(this);
|
||||
if (typeof hljs !== "undefined") {
|
||||
this.querySelectorAll("pre code").forEach((block) => hljs.highlightElement(block));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-content", AppContent);
|
||||
@@ -0,0 +1,138 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
|
||||
export class AppContextMenu extends Component {
|
||||
connectedCallback() {
|
||||
if (this._built) {
|
||||
return;
|
||||
}
|
||||
this._built = true;
|
||||
this.build();
|
||||
this.bindGlobal();
|
||||
}
|
||||
|
||||
build() {
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "context-menu";
|
||||
menu.setAttribute("role", "menu");
|
||||
this.appendChild(menu);
|
||||
this.menu = menu;
|
||||
menu.addEventListener("click", (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
bindGlobal() {
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.menu.contains(e.target)) this.close();
|
||||
});
|
||||
document.addEventListener("scroll", () => this.close(), true);
|
||||
window.addEventListener("resize", () => this.close());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") this.close();
|
||||
});
|
||||
}
|
||||
|
||||
render(items) {
|
||||
this.menu.textContent = "";
|
||||
const list = document.createElement("ul");
|
||||
list.className = "context-menu-list";
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
if (item.separator) {
|
||||
li.className = "context-menu-sep";
|
||||
list.appendChild(li);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "context-menu-item" + (item.danger ? " danger" : "");
|
||||
btn.disabled = !!item.disabled;
|
||||
const icon = item.icon ? `<span class="context-menu-icon">${item.icon}</span>` : "";
|
||||
btn.innerHTML = icon + '<span class="context-menu-label"></span>';
|
||||
btn.querySelector(".context-menu-label").textContent = item.label;
|
||||
if (!item.disabled && item.onSelect) {
|
||||
btn.addEventListener("click", () => {
|
||||
this.close();
|
||||
item.onSelect();
|
||||
});
|
||||
}
|
||||
li.appendChild(btn);
|
||||
list.appendChild(li);
|
||||
});
|
||||
this.menu.appendChild(list);
|
||||
}
|
||||
|
||||
open(x, y, items) {
|
||||
if (!items || !items.length) return;
|
||||
this.render(items);
|
||||
this.menu.classList.add("visible");
|
||||
const rect = this.menu.getBoundingClientRect();
|
||||
let left = x;
|
||||
let top = y;
|
||||
if (left + rect.width > window.innerWidth) left = window.innerWidth - rect.width - 8;
|
||||
if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 8;
|
||||
this.menu.style.left = Math.max(8, left) + "px";
|
||||
this.menu.style.top = Math.max(8, top) + "px";
|
||||
}
|
||||
|
||||
close() {
|
||||
this.menu.classList.remove("visible");
|
||||
}
|
||||
|
||||
suppressNextClick() {
|
||||
const handler = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
document.addEventListener("click", handler, { capture: true, once: true });
|
||||
}
|
||||
|
||||
attach(host, builder) {
|
||||
host.addEventListener("contextmenu", (e) => {
|
||||
const items = builder(e);
|
||||
if (!items || !items.length) return;
|
||||
e.preventDefault();
|
||||
this.open(e.clientX, e.clientY, items);
|
||||
});
|
||||
|
||||
let timer = null;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let fired = false;
|
||||
const clear = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
host.addEventListener("touchstart", (e) => {
|
||||
if (e.touches.length !== 1) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
const touch = e.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
fired = false;
|
||||
timer = setTimeout(() => {
|
||||
fired = true;
|
||||
this.open(startX, startY, builder(e) || []);
|
||||
}, 500);
|
||||
}, { passive: true });
|
||||
host.addEventListener("touchmove", (e) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch && (Math.abs(touch.clientX - startX) > 10 || Math.abs(touch.clientY - startY) > 10)) {
|
||||
clear();
|
||||
}
|
||||
}, { passive: true });
|
||||
host.addEventListener("touchend", () => {
|
||||
clear();
|
||||
if (fired) {
|
||||
fired = false;
|
||||
this.suppressNextClick();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-context-menu", AppContextMenu);
|
||||
@@ -0,0 +1,126 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
|
||||
export class AppDialog extends Component {
|
||||
connectedCallback() {
|
||||
if (this._built) {
|
||||
return;
|
||||
}
|
||||
this._built = true;
|
||||
this.resolver = null;
|
||||
this.mode = "confirm";
|
||||
this.lastFocus = null;
|
||||
this.build();
|
||||
}
|
||||
|
||||
build() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "dialog-overlay";
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.innerHTML =
|
||||
'<div class="card dialog-card">' +
|
||||
'<div class="modal-header"><h3 class="dialog-title"></h3>' +
|
||||
'<button type="button" class="modal-close btn-ghost btn-icon dialog-close">×</button></div>' +
|
||||
'<p class="dialog-message"></p>' +
|
||||
'<div class="dialog-field" hidden><label class="dialog-field-label"></label>' +
|
||||
'<input type="text" class="dialog-input" autocomplete="off"></div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button type="button" class="btn btn-secondary dialog-cancel">Cancel</button>' +
|
||||
'<button type="button" class="btn btn-primary dialog-confirm">OK</button>' +
|
||||
"</div></div>";
|
||||
this.appendChild(overlay);
|
||||
|
||||
this.overlay = overlay;
|
||||
this.titleEl = overlay.querySelector(".dialog-title");
|
||||
this.messageEl = overlay.querySelector(".dialog-message");
|
||||
this.field = overlay.querySelector(".dialog-field");
|
||||
this.fieldLabel = overlay.querySelector(".dialog-field-label");
|
||||
this.input = overlay.querySelector(".dialog-input");
|
||||
this.cancelBtn = overlay.querySelector(".dialog-cancel");
|
||||
this.confirmBtn = overlay.querySelector(".dialog-confirm");
|
||||
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) this.dismiss();
|
||||
});
|
||||
overlay.querySelector(".dialog-close").addEventListener("click", () => this.dismiss());
|
||||
this.cancelBtn.addEventListener("click", () => this.dismiss());
|
||||
this.confirmBtn.addEventListener("click", () => this.accept());
|
||||
this.input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this.accept();
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && this.overlay.classList.contains("visible")) {
|
||||
e.preventDefault();
|
||||
this.dismiss();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
open(mode, options) {
|
||||
const opts = options || {};
|
||||
this.mode = mode;
|
||||
this.titleEl.textContent = opts.title || (mode === "prompt" ? "Rename" : mode === "alert" ? "Notice" : "Confirm");
|
||||
this.messageEl.textContent = opts.message || "";
|
||||
this.messageEl.hidden = !opts.message;
|
||||
this.confirmBtn.textContent = opts.confirmLabel || (mode === "alert" ? "OK" : mode === "prompt" ? "Save" : "Confirm");
|
||||
this.cancelBtn.textContent = opts.cancelLabel || "Cancel";
|
||||
this.cancelBtn.hidden = mode === "alert";
|
||||
this.confirmBtn.classList.toggle("dialog-danger", !!opts.danger);
|
||||
|
||||
if (mode === "prompt") {
|
||||
this.field.hidden = false;
|
||||
this.fieldLabel.textContent = opts.label || "";
|
||||
this.fieldLabel.hidden = !opts.label;
|
||||
this.input.value = opts.value || "";
|
||||
this.input.placeholder = opts.placeholder || "";
|
||||
} else {
|
||||
this.field.hidden = true;
|
||||
}
|
||||
|
||||
this.lastFocus = document.activeElement;
|
||||
this.overlay.classList.add("visible");
|
||||
const focusTarget = mode === "prompt" ? this.input : this.confirmBtn;
|
||||
setTimeout(() => {
|
||||
focusTarget.focus();
|
||||
if (mode === "prompt") this.input.select();
|
||||
}, 20);
|
||||
|
||||
return new Promise((resolve) => { this.resolver = resolve; });
|
||||
}
|
||||
|
||||
accept() {
|
||||
const value = this.mode === "prompt" ? this.input.value : true;
|
||||
this.close(value);
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.close(this.mode === "prompt" ? null : this.mode === "alert" ? undefined : false);
|
||||
}
|
||||
|
||||
close(value) {
|
||||
this.overlay.classList.remove("visible");
|
||||
if (this.lastFocus && this.lastFocus.focus) this.lastFocus.focus();
|
||||
const resolve = this.resolver;
|
||||
this.resolver = null;
|
||||
if (resolve) resolve(value);
|
||||
}
|
||||
|
||||
confirm(options) {
|
||||
return this.open("confirm", options);
|
||||
}
|
||||
|
||||
prompt(options) {
|
||||
return this.open("prompt", options);
|
||||
}
|
||||
|
||||
alert(options) {
|
||||
return this.open("alert", options);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-dialog", AppDialog);
|
||||
@@ -0,0 +1,26 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
|
||||
export class AppToast extends Component {
|
||||
connectedCallback() {
|
||||
this.classList.add("dp-toast-host");
|
||||
}
|
||||
|
||||
show(message, options = {}) {
|
||||
const type = options.type || "info";
|
||||
const ms = options.ms || 3000;
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `dp-toast dp-toast-${type}`;
|
||||
toast.textContent = message;
|
||||
this.appendChild(toast);
|
||||
requestAnimationFrame(() => toast.classList.add("visible"));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("visible");
|
||||
setTimeout(() => toast.remove(), 200);
|
||||
}, ms);
|
||||
return toast;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-toast", AppToast);
|
||||
@@ -0,0 +1,255 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
|
||||
export class AppUpload extends Component {
|
||||
connectedCallback() {
|
||||
if (this._built) {
|
||||
return;
|
||||
}
|
||||
this._built = true;
|
||||
this.mode = this.attr("mode", "attachment");
|
||||
this.endpoint = this.attr("endpoint", "/uploads/upload");
|
||||
this.fieldName = this.attr("field-name", "file");
|
||||
this.uidsName = this.attr("name", "attachment_uids");
|
||||
this.maxBytes = this.intAttr("max-size", 10) * 1024 * 1024;
|
||||
this.maxFiles = this.intAttr("max-files", 10);
|
||||
this.allowedTypes = this.attr("allowed-types", "")
|
||||
.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
||||
this.extraFields = {};
|
||||
this.items = [];
|
||||
this.build();
|
||||
}
|
||||
|
||||
build() {
|
||||
this.button = document.createElement("button");
|
||||
this.button.type = "button";
|
||||
this.button.className = "dp-upload-btn";
|
||||
this.button.innerHTML =
|
||||
'<span class="dp-upload-icon">📎</span>' +
|
||||
'<span class="dp-upload-label"></span>' +
|
||||
'<span class="dp-upload-count" hidden></span>';
|
||||
this.button.querySelector(".dp-upload-label").textContent = this.attr("label", "Attach files");
|
||||
this.countEl = this.button.querySelector(".dp-upload-count");
|
||||
|
||||
this.input = document.createElement("input");
|
||||
this.input.type = "file";
|
||||
this.input.className = "dp-upload-input";
|
||||
this.input.hidden = true;
|
||||
if (this.boolAttr("multiple")) {
|
||||
this.input.multiple = true;
|
||||
}
|
||||
if (this.boolAttr("directory")) {
|
||||
this.input.webkitdirectory = true;
|
||||
}
|
||||
const accept = this.attr("accept", "");
|
||||
if (accept) {
|
||||
this.input.accept = accept;
|
||||
}
|
||||
if (this.mode === "field") {
|
||||
this.input.name = this.fieldName;
|
||||
}
|
||||
|
||||
this.chips = document.createElement("div");
|
||||
this.chips.className = "dp-upload-chips";
|
||||
this.errorEl = document.createElement("div");
|
||||
this.errorEl.className = "dp-upload-error";
|
||||
|
||||
this.button.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
this.open();
|
||||
});
|
||||
this.button.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
this.button.classList.add("dragover");
|
||||
});
|
||||
this.button.addEventListener("dragleave", () => this.button.classList.remove("dragover"));
|
||||
this.button.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
this.button.classList.remove("dragover");
|
||||
this.handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
this.input.addEventListener("change", () => this.handleFiles(this.input.files));
|
||||
|
||||
this.append(this.button, this.input, this.chips, this.errorEl);
|
||||
|
||||
if (this.mode === "attachment") {
|
||||
this.uidsInput = document.createElement("input");
|
||||
this.uidsInput.type = "hidden";
|
||||
this.uidsInput.name = this.uidsName;
|
||||
this.append(this.uidsInput);
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.input.click();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.items = [];
|
||||
if (this.mode === "field") {
|
||||
this.input.value = "";
|
||||
}
|
||||
if (this.uidsInput) {
|
||||
this.uidsInput.value = "";
|
||||
}
|
||||
this.renderChips();
|
||||
this.updateCount();
|
||||
}
|
||||
|
||||
async handleFiles(fileList) {
|
||||
this.clearError();
|
||||
const files = Array.from(fileList || []);
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
if (this.mode === "field") {
|
||||
this.setFieldFiles(files);
|
||||
return;
|
||||
}
|
||||
if (this.mode === "attachment") {
|
||||
const remaining = this.maxFiles - this.items.length;
|
||||
if (files.length > remaining) {
|
||||
this.showError(`You can only add ${Math.max(0, remaining)} more file(s).`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const file of files) {
|
||||
if (this.validate(file)) {
|
||||
await this.upload(file);
|
||||
}
|
||||
}
|
||||
this.input.value = "";
|
||||
if (this.mode === "direct") {
|
||||
this.emit("done", {});
|
||||
}
|
||||
}
|
||||
|
||||
validate(file) {
|
||||
if (file.size > this.maxBytes) {
|
||||
this.showError(`"${file.name}" exceeds the ${this.intAttr("max-size", 10)}MB limit.`);
|
||||
return false;
|
||||
}
|
||||
if (this.allowedTypes.length) {
|
||||
const ext = "." + file.name.split(".").pop().toLowerCase();
|
||||
if (!this.allowedTypes.includes(ext)) {
|
||||
this.showError(`"${file.name}" type is not allowed.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async upload(file) {
|
||||
this.button.classList.add("uploading");
|
||||
const body = new FormData();
|
||||
body.append(this.fieldName, file);
|
||||
for (const [key, value] of Object.entries(this.extraFields)) {
|
||||
body.append(key, value);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(this.endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Accept": "application/json", "X-Requested-With": "fetch" },
|
||||
body,
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.ok === false || data.error) {
|
||||
const message = (data.error && (data.error.message || data.error)) || "Upload failed";
|
||||
throw new Error(message);
|
||||
}
|
||||
if (this.mode === "attachment" && data.uid) {
|
||||
this.items.push({ uid: data.uid, name: file.name });
|
||||
this.syncUids();
|
||||
this.renderChips();
|
||||
}
|
||||
this.emit("uploaded", { file, result: data });
|
||||
} catch (error) {
|
||||
this.showError(error.message);
|
||||
this.emit("error", { file, message: error.message });
|
||||
} finally {
|
||||
this.button.classList.remove("uploading");
|
||||
this.updateCount();
|
||||
}
|
||||
}
|
||||
|
||||
setFieldFiles(files) {
|
||||
if (this.input.multiple) {
|
||||
this.items.push(...files.map((file) => ({ file, name: file.name })));
|
||||
} else {
|
||||
this.items = files.slice(0, 1).map((file) => ({ file, name: file.name }));
|
||||
}
|
||||
this.applyFieldInput();
|
||||
this.renderChips();
|
||||
this.updateCount();
|
||||
}
|
||||
|
||||
applyFieldInput() {
|
||||
const transfer = new DataTransfer();
|
||||
for (const item of this.items) {
|
||||
transfer.items.add(item.file);
|
||||
}
|
||||
this.input.files = transfer.files;
|
||||
}
|
||||
|
||||
removeAt(index) {
|
||||
this.items.splice(index, 1);
|
||||
if (this.mode === "attachment") {
|
||||
this.syncUids();
|
||||
}
|
||||
if (this.mode === "field") {
|
||||
this.applyFieldInput();
|
||||
}
|
||||
this.renderChips();
|
||||
this.updateCount();
|
||||
}
|
||||
|
||||
renderChips() {
|
||||
this.chips.textContent = "";
|
||||
this.items.forEach((item, index) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "dp-upload-chip";
|
||||
const name = document.createElement("span");
|
||||
name.className = "dp-upload-chip-name";
|
||||
name.textContent = item.name;
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "dp-upload-chip-remove";
|
||||
remove.innerHTML = "×";
|
||||
remove.setAttribute("aria-label", `Remove ${item.name}`);
|
||||
remove.addEventListener("click", () => this.removeAt(index));
|
||||
chip.append(name, remove);
|
||||
this.chips.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
updateCount() {
|
||||
const count = this.items.length;
|
||||
this.countEl.hidden = count === 0;
|
||||
this.countEl.textContent = count ? `(${count})` : "";
|
||||
this.emit("change", { count });
|
||||
}
|
||||
|
||||
syncUids() {
|
||||
if (this.uidsInput) {
|
||||
this.uidsInput.value = this.items.map((item) => item.uid).join(",");
|
||||
}
|
||||
}
|
||||
|
||||
emit(name, detail) {
|
||||
this.dispatchEvent(new CustomEvent(`dp-upload:${name}`, { detail, bubbles: true }));
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.errorEl.textContent = message;
|
||||
if (window.app && window.app.toast) {
|
||||
window.app.toast.show(message, { type: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.errorEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-upload", AppUpload);
|
||||
@@ -0,0 +1,17 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export class Component extends HTMLElement {
|
||||
attr(name, fallback = "") {
|
||||
const value = this.getAttribute(name);
|
||||
return value === null ? fallback : value;
|
||||
}
|
||||
|
||||
boolAttr(name) {
|
||||
return this.hasAttribute(name);
|
||||
}
|
||||
|
||||
intAttr(name, fallback = 0) {
|
||||
const value = parseInt(this.getAttribute(name), 10);
|
||||
return Number.isNaN(value) ? fallback : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import "./AppAvatar.js";
|
||||
import "./AppCode.js";
|
||||
import "./AppContent.js";
|
||||
import "./AppUpload.js";
|
||||
import "./AppToast.js";
|
||||
import "./AppDialog.js";
|
||||
import "./AppContextMenu.js";
|
||||
@@ -58,12 +58,20 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
window.addEventListener("beforeunload", () => this._captureFocusForUnload());
|
||||
this._identity = null;
|
||||
this._fetchIdentity().then((id) => { this._identity = id; });
|
||||
window.addEventListener("focus", () => this._checkIdentity());
|
||||
window.addEventListener("focus", () => {
|
||||
this._checkIdentity();
|
||||
this._reportVisibility();
|
||||
});
|
||||
window.addEventListener("blur", () => this._reportVisibility());
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) this._checkIdentity();
|
||||
this._reportVisibility();
|
||||
});
|
||||
this.socket = new DeviiSocket({
|
||||
onOpen: () => this._notice("connected."),
|
||||
onOpen: () => {
|
||||
this._notice("connected.");
|
||||
this._reportVisibility();
|
||||
},
|
||||
onClose: () => this._notice("disconnected, reconnecting..."),
|
||||
onMessage: (message) => this._onMessage(message),
|
||||
});
|
||||
@@ -479,6 +487,15 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
_reportVisibility() {
|
||||
if (!this.socket) return;
|
||||
this.socket.send({
|
||||
type: "visibility",
|
||||
visible: !document.hidden,
|
||||
focused: document.hasFocus(),
|
||||
});
|
||||
}
|
||||
|
||||
async _clientRequest(message) {
|
||||
let result;
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<img src="{{ att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% elif att.get('is_image') %}
|
||||
<img src="{{ att['url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% elif att.get('is_video') %}
|
||||
<video src="{{ att['url'] }}" controls preload="metadata" class="gallery-video"></video>
|
||||
{% else %}
|
||||
<a href="{{ att['url'] }}" target="_blank" rel="noopener" class="non-image" download="{{ att.get('original_filename', 'file') }}">
|
||||
<span class="icon">{{ file_icon_emoji(att.get('original_filename', 'file')) }}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="attachment-upload-container" data-attachment-upload
|
||||
data-max-size="{{ max_upload_size_mb() }}"
|
||||
data-max-files="{{ max_attachments_per_resource() }}"
|
||||
data-allowed-types="{{ allowed_file_types() }}"></div>
|
||||
<dp-upload multiple
|
||||
max-size="{{ max_upload_size_mb() }}"
|
||||
max-files="{{ max_attachments_per_resource() }}"
|
||||
allowed-types="{{ allowed_file_types() }}"></dp-upload>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
</a>
|
||||
<textarea name="content" placeholder="Your opinion goes here..." required maxlength="1000" class="emoji-picker-target" data-mention></textarea>
|
||||
<div class="comment-form-actions">
|
||||
<div class="attachment-upload-container" data-attachment-upload
|
||||
data-max-size="{{ max_upload_size_mb() }}"
|
||||
data-max-files="{{ max_attachments_per_resource() }}"
|
||||
data-allowed-types="{{ allowed_file_types() }}"></div>
|
||||
<dp-upload multiple
|
||||
max-size="{{ max_upload_size_mb() }}"
|
||||
max-files="{{ max_attachments_per_resource() }}"
|
||||
allowed-types="{{ allowed_file_types() }}"></dp-upload>
|
||||
<button type="submit" class="comment-form-submit"><span class="icon">📤</span> Post</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
|
||||
<div class="admin-field">
|
||||
<label for="allowed_file_types">Allowed File Types</label>
|
||||
<input type="text" id="allowed_file_types" name="allowed_file_types" value="{{ settings.get('allowed_file_types', '') }}" maxlength="1000" placeholder="e.g. .png,.jpg,.pdf,.zip (leave empty for default)">
|
||||
<small class="hint-text">Comma-separated list of allowed extensions. Leave empty to allow all supported types.</small>
|
||||
<input type="text" id="allowed_file_types" name="allowed_file_types" value="{{ settings.get('allowed_file_types', '') }}" maxlength="1000" placeholder="e.g. .png,.jpg,.mp4,.webm (leave empty for default)">
|
||||
<small class="hint-text">Comma-separated list of allowed extensions. When set, this is the authoritative allowlist; any extension listed here is enabled and its type auto-detected. Leave empty to use the built-in defaults, which include common image and video formats (mp4, webm, ogv, mov, m4v).</small>
|
||||
</div>
|
||||
|
||||
<div class="admin-field">
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
<tr>
|
||||
<td>
|
||||
<div class="admin-user-cell">
|
||||
<img src="{{ avatar_url('multiavatar', u['username'], 24) }}" class="avatar-img" style="width: 24px; height: 24px; border-radius: 50%;" alt="">
|
||||
<span>{{ u['username'] }}</span>
|
||||
{% set _user = u %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% include "_user_link.html" %}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ u['email'] }}</td>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
<link rel="stylesheet" href="/static/css/variables.css">
|
||||
<link rel="stylesheet" href="/static/css/base.css">
|
||||
<link rel="stylesheet" href="/static/css/components.css">
|
||||
<link rel="stylesheet" href="/static/vendor/atom-one-dark.min.css">
|
||||
<link rel="stylesheet" href="/static/css/markdown.css">
|
||||
<link rel="stylesheet" href="/static/css/mention.css">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<div class="docs-content" data-render>
|
||||
# devii-avatar
|
||||
|
||||
The animated assistant avatar (a vendored md-clippy character). It is a controller element: it
|
||||
renders no markup of its own but drives the on-screen character and can speak, animate, and move.
|
||||
The `DeviiTerminal` controller creates one as `#devii` and links it to the terminal.
|
||||
|
||||
Source: `static/js/devii/devii-avatar.js`.
|
||||
|
||||
## Attributes
|
||||
|
||||
| Attribute | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `character` | string | `Clippy` | Which character to display. |
|
||||
| `tts` | boolean | off | Enable text-to-speech. |
|
||||
| `autoshow` | boolean | off | Show the character on connection. |
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `execute(action, args)` | Run an avatar action: `show`, `hide`, `speak`, `play_animation`, `random_animation`, `move_to`, `gesture_at`, `stop`, `switch_character`, `list_animations`, `list_characters`, `get_viewport`. |
|
||||
| `ask(text)` | Send a prompt to the connected assistant. |
|
||||
|
||||
## Usage
|
||||
|
||||
```html
|
||||
<devii-avatar character="Clippy" autoshow></devii-avatar>
|
||||
```
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example - shows the avatar and plays an animation</div>
|
||||
<div class="component-demo-stage">
|
||||
<button type="button" class="btn btn-primary" id="avatar-demo-btn">Show avatar</button>
|
||||
<span id="avatar-demo-result" class="text-muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module">
|
||||
const result = document.getElementById("avatar-demo-result");
|
||||
document.getElementById("avatar-demo-btn").addEventListener("click", async () => {
|
||||
const avatar = document.querySelector("devii-avatar");
|
||||
if (!avatar || typeof avatar.execute !== "function") {
|
||||
result.textContent = "Avatar is not available on this page.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await avatar.execute("show");
|
||||
await avatar.execute("random_animation");
|
||||
result.textContent = "Played a random animation.";
|
||||
} catch (error) {
|
||||
result.textContent = "Avatar could not start.";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="docs-content" data-render>
|
||||
# devii-terminal
|
||||
|
||||
The draggable terminal window for the Devii assistant. It connects to `/devii/ws`, renders
|
||||
markdown replies, keeps input history, and supports normal, maximized, and fullscreen states with
|
||||
geometry persisted to `localStorage`. It is created automatically by the `DeviiTerminal`
|
||||
controller (`app.devii`) and appended to the page; you normally do not place it by hand.
|
||||
|
||||
Source: `static/js/devii/devii-terminal.js`.
|
||||
|
||||
## Attributes
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|---|---|---|
|
||||
| `open` | boolean | Open the terminal on connection. |
|
||||
| `avatar` | string | Id or selector of an associated `devii-avatar` element. |
|
||||
|
||||
## Methods and properties
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `open()` / `close()` / `toggle()` | Control visibility. |
|
||||
| `state` | `closed`, `normal`, `maximized`, or `fullscreen`. |
|
||||
| `geometry` | `{ width, height, left, top }` window placement. |
|
||||
|
||||
## Usage
|
||||
|
||||
The terminal is opened from any element marked `data-devii-open` (the controller delegates these
|
||||
clicks), so you rarely instantiate it directly:
|
||||
|
||||
```html
|
||||
<button data-devii-open>Ask Devii</button>
|
||||
```
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example</div>
|
||||
<div class="component-demo-stage">
|
||||
<button type="button" class="btn btn-primary" data-devii-open>Open Devii terminal</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="docs-content" data-render>
|
||||
# dp-avatar
|
||||
|
||||
Renders a circular user avatar image generated from a username seed (the local Multiavatar
|
||||
service at `/avatar/multiavatar/{seed}`). A thin element wrapper around the `Avatar` helper.
|
||||
|
||||
Source: `static/js/components/AppAvatar.js`.
|
||||
|
||||
## Attributes
|
||||
|
||||
| Attribute | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `username` | string | (required) | Seed used to generate and label the avatar. |
|
||||
| `size` | integer | `24` | Width and height in pixels. |
|
||||
|
||||
Both attributes are observed: changing them re-renders the avatar.
|
||||
|
||||
## Usage
|
||||
|
||||
```html
|
||||
<dp-avatar username="alice_test" size="64"></dp-avatar>
|
||||
```
|
||||
|
||||
No JavaScript is required - the element renders itself on connection.
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example</div>
|
||||
<div class="component-demo-stage">
|
||||
<dp-avatar username="alice_test" size="32"></dp-avatar>
|
||||
<dp-avatar username="bob_test" size="48"></dp-avatar>
|
||||
<dp-avatar username="devplace" size="64"></dp-avatar>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppAvatar.js";
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="docs-content" data-render>
|
||||
# dp-code
|
||||
|
||||
A syntax-highlighted code block with a copy button and optional line numbers. Wraps the
|
||||
`CodeBlock` helper (highlight.js plus the copy/line-number affordances).
|
||||
|
||||
Source: `static/js/components/AppCode.js`.
|
||||
|
||||
## Attributes
|
||||
|
||||
| Attribute | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `language` | string | (auto) | Sets `language-{value}` on the inner `<code>` for highlighting. |
|
||||
| `line-numbers` | boolean | off | When present, renders a line-number gutter. |
|
||||
|
||||
## Usage
|
||||
|
||||
Provide a `<pre><code>` child, or plain text and the element wraps it:
|
||||
|
||||
```html
|
||||
<dp-code language="python" line-numbers>
|
||||
<pre><code>def hello():
|
||||
return "world"</code></pre>
|
||||
</dp-code>
|
||||
```
|
||||
|
||||
The element enhances its content once on connection: highlighting, a copy button, and (when
|
||||
`line-numbers` is set) a gutter.
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example</div>
|
||||
<dp-code language="javascript" line-numbers><pre><code>export class Greeter {
|
||||
greet(name) {
|
||||
return `Hello, ${name}`;
|
||||
}
|
||||
}</code></pre></dp-code>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppCode.js";
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="docs-content" data-render>
|
||||
# dp-content
|
||||
|
||||
Renders user-supplied text as safe rich content: emoji shortcodes, GitHub-flavored markdown,
|
||||
then `DOMPurify` sanitisation, then media embeds (images, video, YouTube), mentions, and
|
||||
autolinks. It wraps the shared `contentRenderer` engine (`static/js/ContentRenderer.js`) - the
|
||||
same pipeline used by the site's `data-render` attribute - exposed as a custom element.
|
||||
|
||||
Source: `static/js/components/AppContent.js`.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- On connection the element reads its own text, renders it once, and replaces its contents with
|
||||
the sanitised HTML; code blocks are highlighted.
|
||||
- Sanitisation is fail-closed: rendering throws if `DOMPurify` is unavailable rather than
|
||||
emitting unsanitised HTML.
|
||||
- Equivalent to putting `data-render` on a server-rendered element, but as a self-contained
|
||||
element you can drop in directly.
|
||||
|
||||
## Usage
|
||||
|
||||
Put markdown (or plain text) as the element's text. Escape any literal HTML you do not want
|
||||
treated as live markup:
|
||||
|
||||
```html
|
||||
<dp-content>
|
||||
# Hello
|
||||
Visit **DevPlace** :rocket: and watch https://youtu.be/dQw4w9WgXcQ
|
||||
</dp-content>
|
||||
```
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example</div>
|
||||
<dp-content>## Markdown, rendered live
|
||||
|
||||
This is **bold**, this is _italic_, and here is a shortcode :rocket: :fire:.
|
||||
|
||||
- a list item
|
||||
- another with `inline code`
|
||||
- a mention of @alice_test and a link to https://example.com
|
||||
|
||||
```js
|
||||
const answer = 42;
|
||||
```
|
||||
</dp-content>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppContent.js";
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<div class="docs-content" data-render>
|
||||
# dp-context-menu
|
||||
|
||||
A custom right-click (and long-press on touch) context menu. A single instance is created by
|
||||
`Application.js` and reachable as `app.contextMenu`.
|
||||
|
||||
Source: `static/js/components/AppContextMenu.js`.
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `attach(host, builder)` | Bind to an element. `builder(event)` returns the item array to show, or an empty array to suppress. Handles desktop right-click and a 500ms touch long-press. |
|
||||
| `open(x, y, items)` | Open the menu at viewport coordinates with the given items. |
|
||||
| `close()` | Hide the menu. |
|
||||
|
||||
Each item: `{ label, onSelect, icon?, danger?, disabled?, separator? }`. Use `{ separator: true }`
|
||||
for a divider. The menu auto-closes on selection, outside click, scroll, resize, or Escape, and
|
||||
repositions to stay within the viewport.
|
||||
|
||||
## Usage
|
||||
|
||||
```html
|
||||
<dp-context-menu></dp-context-menu>
|
||||
```
|
||||
|
||||
```javascript
|
||||
app.contextMenu.attach(myElement, (event) => [
|
||||
{ label: "Open", onSelect: () => open() },
|
||||
{ separator: true },
|
||||
{ label: "Delete", danger: true, onSelect: () => remove() },
|
||||
]);
|
||||
```
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example - right-click (or long-press) the box</div>
|
||||
<div class="component-demo-stage">
|
||||
<div id="ctx-target" class="card" style="padding:1.5rem;cursor:context-menu;user-select:none;">
|
||||
Right-click here
|
||||
</div>
|
||||
<span id="ctx-result" class="text-muted"></span>
|
||||
</div>
|
||||
<dp-context-menu id="ctx-menu"></dp-context-menu>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppContextMenu.js";
|
||||
const menu = document.getElementById("ctx-menu");
|
||||
const target = document.getElementById("ctx-target");
|
||||
const result = document.getElementById("ctx-result");
|
||||
menu.attach(target, () => [
|
||||
{ label: "Open", icon: "\u{1F4C2}", onSelect: () => { result.textContent = "Open selected"; } },
|
||||
{ label: "Rename", icon: "\u{270F}", onSelect: () => { result.textContent = "Rename selected"; } },
|
||||
{ separator: true },
|
||||
{ label: "Delete", icon: "\u{1F5D1}", danger: true, onSelect: () => { result.textContent = "Delete selected"; } },
|
||||
]);
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<div class="docs-content" data-render>
|
||||
# dp-dialog
|
||||
|
||||
A promise-based modal for confirmations, prompts, and alerts. A single instance is created by
|
||||
`Application.js` and reachable as `app.dialog`; it replaces the browser's blocking
|
||||
`confirm`/`prompt`/`alert`.
|
||||
|
||||
Source: `static/js/components/AppDialog.js`.
|
||||
|
||||
## Methods
|
||||
|
||||
Each returns a Promise resolving when the user responds.
|
||||
|
||||
| Method | Resolves with |
|
||||
|---|---|
|
||||
| `confirm(options)` | `true` if confirmed, `false` if cancelled or dismissed. |
|
||||
| `prompt(options)` | the entered string, or `null` if cancelled. |
|
||||
| `alert(options)` | `undefined` once acknowledged. |
|
||||
|
||||
`options`: `title`, `message`, `confirmLabel`, `cancelLabel`, `danger` (red confirm button),
|
||||
and for `prompt`: `label`, `value`, `placeholder`.
|
||||
|
||||
## Usage
|
||||
|
||||
```html
|
||||
<dp-dialog></dp-dialog>
|
||||
```
|
||||
|
||||
```javascript
|
||||
const ok = await app.dialog.confirm({
|
||||
title: "Delete post",
|
||||
message: "This cannot be undone.",
|
||||
danger: true,
|
||||
});
|
||||
if (ok) {
|
||||
const name = await app.dialog.prompt({ label: "New name", value: "untitled" });
|
||||
}
|
||||
```
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example</div>
|
||||
<div class="component-demo-stage">
|
||||
<button type="button" class="btn btn-primary" id="dlg-confirm">Confirm</button>
|
||||
<button type="button" class="btn btn-secondary" id="dlg-prompt">Prompt</button>
|
||||
<button type="button" class="btn btn-secondary" id="dlg-alert">Alert</button>
|
||||
<span id="dlg-result" class="text-muted"></span>
|
||||
</div>
|
||||
<dp-dialog id="dialog-demo"></dp-dialog>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppDialog.js";
|
||||
const dialog = document.getElementById("dialog-demo");
|
||||
const result = document.getElementById("dlg-result");
|
||||
document.getElementById("dlg-confirm").addEventListener("click", async () => {
|
||||
const ok = await dialog.confirm({ title: "Confirm", message: "Proceed with this action?" });
|
||||
result.textContent = `confirm returned: ${ok}`;
|
||||
});
|
||||
document.getElementById("dlg-prompt").addEventListener("click", async () => {
|
||||
const value = await dialog.prompt({ title: "Rename", label: "New name", value: "untitled" });
|
||||
result.textContent = `prompt returned: ${JSON.stringify(value)}`;
|
||||
});
|
||||
document.getElementById("dlg-alert").addEventListener("click", async () => {
|
||||
await dialog.alert({ title: "Notice", message: "This is an alert." });
|
||||
result.textContent = "alert acknowledged";
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<div class="docs-content" data-render>
|
||||
# dp-toast
|
||||
|
||||
A transient notification host. It stacks short messages in the bottom-right corner and fades
|
||||
each one out. A single instance is created by `Application.js` and reachable as `app.toast`.
|
||||
|
||||
Source: `static/js/components/AppToast.js`.
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `show(message, options)` | Display `message`. Options: `type` (`info`, `success`, `error`; default `info`) and `ms` (lifetime in milliseconds, default `3000`). Returns the toast element. |
|
||||
|
||||
## Usage
|
||||
|
||||
```html
|
||||
<dp-toast></dp-toast>
|
||||
```
|
||||
|
||||
```javascript
|
||||
app.toast.show("Saved", { type: "success" });
|
||||
app.toast.show("Something went wrong", { type: "error", ms: 5000 });
|
||||
```
|
||||
|
||||
The element renders nothing until `show` is called.
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example</div>
|
||||
<div class="component-demo-stage">
|
||||
<button type="button" class="btn btn-secondary" data-toast="info">Info</button>
|
||||
<button type="button" class="btn btn-primary" data-toast="success">Success</button>
|
||||
<button type="button" class="btn btn-danger" data-toast="error">Error</button>
|
||||
</div>
|
||||
<dp-toast id="toast-demo"></dp-toast>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppToast.js";
|
||||
const toast = document.getElementById("toast-demo");
|
||||
const messages = { info: "Heads up", success: "Saved", error: "Something went wrong" };
|
||||
document.querySelectorAll("[data-toast]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const type = btn.getAttribute("data-toast");
|
||||
toast.show(messages[type], { type });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<div class="docs-content" data-render>
|
||||
# dp-upload
|
||||
|
||||
A clean, tunable file-upload button. It shows a compact button with a selected-count badge and a
|
||||
row of removable filename chips - no thumbnail grid. It replaces the native `<input type="file">`
|
||||
and the legacy attachment uploader everywhere in the app.
|
||||
|
||||
Source: `static/js/components/AppUpload.js`.
|
||||
|
||||
## Modes
|
||||
|
||||
Set with the `mode` attribute:
|
||||
|
||||
| Mode | Behaviour |
|
||||
|---|---|
|
||||
| `attachment` (default) | On select, each file is uploaded to `endpoint` immediately; the returned UIDs are kept in a hidden `<input name="attachment_uids">` that submits with the surrounding form. |
|
||||
| `direct` | Each file is uploaded to `endpoint` immediately with `field-name` plus any `extraFields`; emits events instead of writing a form field. Used for direct-to-app uploads (e.g. the project file browser). |
|
||||
| `field` | No AJAX. Wraps a real `<input type="file" name="{field-name}">` whose files submit with the form (e.g. the create-post inline image). |
|
||||
|
||||
## Attributes
|
||||
|
||||
| Attribute | Default | Description |
|
||||
|---|---|---|
|
||||
| `mode` | `attachment` | `attachment`, `direct`, or `field`. |
|
||||
| `label` | `Attach files` | Button text. |
|
||||
| `multiple` | off | Allow selecting more than one file. |
|
||||
| `directory` | off | Allow selecting a whole directory (`webkitdirectory`). |
|
||||
| `accept` | (none) | Native accept filter, e.g. `image/*`. |
|
||||
| `allowed-types` | (none) | Comma list of permitted extensions, e.g. `.jpg,.png`. |
|
||||
| `max-size` | `10` | Maximum size per file, in MB. |
|
||||
| `max-files` | `10` | Maximum number of files. |
|
||||
| `endpoint` | `/uploads/upload` | Upload URL (attachment / direct). |
|
||||
| `name` | `attachment_uids` | Hidden field name for collected UIDs (attachment mode). |
|
||||
| `field-name` | `file` | File field name in upload requests / native field mode. |
|
||||
|
||||
## Methods and properties
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `open()` | Open the file picker programmatically. |
|
||||
| `clear()` | Remove all selected files. |
|
||||
| `extraFields` | Object of extra form fields sent with each upload (direct mode), e.g. `{ path }`. |
|
||||
|
||||
## Events
|
||||
|
||||
`dp-upload:uploaded` (per file, detail `{file, result}`), `dp-upload:error` (detail
|
||||
`{file, message}`), `dp-upload:done` (direct mode, after a batch), `dp-upload:change`
|
||||
(detail `{count}`).
|
||||
|
||||
## Usage
|
||||
|
||||
```html
|
||||
<dp-upload multiple max-size="10" max-files="5" allowed-types=".jpg,.png,.pdf"></dp-upload>
|
||||
<dp-upload mode="field" field-name="image" accept="image/*" label="Add image"></dp-upload>
|
||||
```
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example - select files to see the count and chips</div>
|
||||
<dp-upload mode="field" field-name="demo" multiple label="Choose files"></dp-upload>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/js/components/AppUpload.js";
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<div class="docs-content" data-render>
|
||||
# emoji-picker
|
||||
|
||||
A vendored third-party emoji picker (`emoji-picker-element`) with category browsing, search, and
|
||||
skin-tone selection. DevPlace loads it globally and uses it on comment forms via the
|
||||
`EmojiPicker` wrapper (textareas marked `.emoji-picker-target`).
|
||||
|
||||
Source: `static/vendor/emoji-picker-element/`.
|
||||
|
||||
## Attributes
|
||||
|
||||
| Attribute | Description |
|
||||
|---|---|
|
||||
| `locale` | Language for emoji labels (for example `en`). |
|
||||
| `skin-tone-emoji` | Default skin-tone emoji. |
|
||||
| `emoji-version` | Restrict to a Unicode emoji version. |
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Detail |
|
||||
|---|---|
|
||||
| `emoji-click` | `{ unicode, emoji, ... }` for the chosen emoji (primary event). |
|
||||
| `skin-tone-change` | The selected skin tone. |
|
||||
|
||||
## Usage
|
||||
|
||||
```html
|
||||
<emoji-picker></emoji-picker>
|
||||
```
|
||||
|
||||
```javascript
|
||||
picker.addEventListener("emoji-click", (event) => {
|
||||
insert(event.detail.unicode);
|
||||
});
|
||||
```
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example - pick an emoji</div>
|
||||
<div class="component-demo-stage">
|
||||
<emoji-picker></emoji-picker>
|
||||
<span id="emoji-demo-result" class="text-muted">No emoji selected yet.</span>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import "/static/vendor/emoji-picker-element/index.js";
|
||||
const result = document.getElementById("emoji-demo-result");
|
||||
document.querySelector(".component-demo emoji-picker").addEventListener("emoji-click", (event) => {
|
||||
result.textContent = `Selected: ${event.detail.unicode}`;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Components overview
|
||||
|
||||
DevPlace ships a small set of reusable **custom HTML web components** (custom elements). They
|
||||
are plain ES6 modules under `static/js/components/`, registered once through
|
||||
`components/index.js` (imported by `Application.js`, which loads on every page), so each element
|
||||
is available everywhere - including these documentation pages.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Prefix.** Project components use the `dp-` prefix (`dp-avatar`, `dp-code`, `dp-toast`,
|
||||
`dp-dialog`, `dp-context-menu`). The assistant ships its own `devii-` elements, and the
|
||||
emoji picker is a vendored third-party element.
|
||||
- **Light DOM.** Components render into the light DOM (no shadow root) so the site's global CSS
|
||||
applies to them directly, matching the existing `devii-*` elements.
|
||||
- **Base class.** Most extend `Component` (`static/js/components/Component.js`), a thin
|
||||
`HTMLElement` subclass with attribute helpers (`attr`, `boolAttr`, `intAttr`).
|
||||
- **Singletons.** Behavioural singletons (`dp-dialog`, `dp-context-menu`, `dp-toast`) are
|
||||
created once by `Application.js` and reachable as `app.dialog`, `app.contextMenu`, and
|
||||
`app.toast`.
|
||||
|
||||
## Catalog
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| [dp-avatar](/docs/component-dp-avatar.html) | User avatar image from a username seed. |
|
||||
| [dp-code](/docs/component-dp-code.html) | Syntax-highlighted code block with copy button. |
|
||||
| [dp-content](/docs/component-dp-content.html) | Render markdown/emoji/media as safe sanitised HTML. |
|
||||
| [dp-upload](/docs/component-dp-upload.html) | Clean file upload button with count and removable chips. |
|
||||
| [dp-toast](/docs/component-dp-toast.html) | Transient corner notifications. |
|
||||
| [dp-dialog](/docs/component-dp-dialog.html) | Promise-based confirm, prompt, and alert modal. |
|
||||
| [dp-context-menu](/docs/component-dp-context-menu.html) | Right-click / long-press context menu. |
|
||||
| [devii-terminal](/docs/component-devii-terminal.html) | The Devii assistant terminal window. |
|
||||
| [devii-avatar](/docs/component-devii-avatar.html) | The Devii animated avatar character. |
|
||||
| [emoji-picker](/docs/component-emoji-picker.html) | Vendored emoji picker element. |
|
||||
|
||||
## A note on scope
|
||||
|
||||
Components are the self-contained, presentational UI pieces. The rest of the frontend is
|
||||
intentionally plain ES6 modules: page controllers that enhance server-rendered partials (votes,
|
||||
reactions, comments, polls) and utilities (HTTP, DOM helpers). Those are not custom elements
|
||||
because they operate on markup produced server-side rather than rendering standalone UI.
|
||||
</div>
|
||||
@@ -14,6 +14,10 @@ Devii runs as `DeviiService` (a background service) and is configured on
|
||||
- `devii_ai_key` - the AI key (falls back to the `DEVII_AI_KEY` environment variable).
|
||||
- `devii_base_url` - the platform origin Devii drives via each user's API key (blank uses this instance).
|
||||
|
||||
**Reliability**
|
||||
- `devii_timeout` - read timeout in seconds for each AI chat-completions call (and the platform API client). Default 300, minimum five minutes. Set this at least as high as the gateway's upstream timeout, or large prompts abort early with a model error.
|
||||
- `devii_fetch_timeout` - read timeout in seconds for the fetch tool when Devii retrieves a URL. Default 300, minimum five minutes.
|
||||
|
||||
**Agent**
|
||||
- `devii_plan_required` / `devii_verify_required` - enforce the plan-first and verify-after-mutation gates.
|
||||
- `devii_max_iterations` - upper bound on tool-loop iterations per turn.
|
||||
@@ -29,9 +33,10 @@ Devii runs as `DeviiService` (a background service) and is configured on
|
||||
**Web search**
|
||||
- `devii_rsearch_enabled` - enable the external `rsearch_*` web tools (web/image search, web-grounded AI answer, chat, image description). When off, those tools are refused. They reach an external public service, not this platform, and Devii uses them only when the user explicitly asks to search the web.
|
||||
- `devii_rsearch_url` - base URL of the rsearch-compatible service those tools call.
|
||||
- `devii_rsearch_timeout` - read timeout in seconds for the `rsearch_*` tools (default 300, minimum five minutes). Web-grounded answers can take several minutes, so this is deliberately generous and separate from the fetch timeout.
|
||||
|
||||
Each field falls back to its `DEVII_*` environment variable where applicable
|
||||
(`DEVII_RSEARCH_ENABLED`, `DEVII_RSEARCH_URL`). The service exposes
|
||||
(`DEVII_RSEARCH_ENABLED`, `DEVII_RSEARCH_URL`, `DEVII_RSEARCH_TIMEOUT`). The service exposes
|
||||
live metrics (active sessions, connections, 24h spend, caps, model) via `collect_metrics`, shown on
|
||||
its services card.
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@ routes it, and an auth scope. See also [Devii internals](/docs/devii-internals.h
|
||||
|
||||
## How tools are declared
|
||||
|
||||
`actions/spec.py` defines `Action` (name, method, path, params, `requires_auth`, `handler`) and
|
||||
`Catalog`. `registry.py` unions the per-category tuples into `CATALOG`. `tool_schema()` projects
|
||||
each action into the JSON tool schema sent to the model; `tool_schemas_for(authenticated)` is the
|
||||
**auth scope filter** - guests only receive actions where `requires_auth` is false, so
|
||||
account-operating tools are not even offered to a guest.
|
||||
`actions/spec.py` defines `Action` (name, method, path, params, `requires_auth`, `requires_admin`,
|
||||
`handler`) and `Catalog`. `registry.py` unions the per-category tuples into `CATALOG`.
|
||||
`tool_schema()` projects each action into the JSON tool schema sent to the model;
|
||||
`tool_schemas_for(authenticated, is_admin)` is the **scope filter** - guests only receive actions
|
||||
where `requires_auth` is false, and `requires_admin` actions are offered only to administrators, so
|
||||
account-operating and admin-only tools are not even presented to a guest or member.
|
||||
|
||||
## Handler categories (scopes)
|
||||
|
||||
@@ -23,7 +24,7 @@ account-operating tools are not even offered to a guest.
|
||||
| `docs` | knowledge | `search_docs` over this documentation. |
|
||||
| `fetch` | web | `fetch_url` with an SSRF guard and size caps. |
|
||||
| `rsearch` | external web | `rsearch` (web/image search), `rsearch_answer` (web-grounded AI answer), `rsearch_chat` (direct AI chat), `rsearch_describe_image` (vision). They call an external public service, not this platform, and are gated by the `devii_rsearch_enabled` config (URL configurable via `devii_rsearch_url`). |
|
||||
| `cost` | accounting | `cost_stats` for the live session. |
|
||||
| `cost` | accounting | `usage_quota` reports the owner's 24h quota usage as a percentage (no money, available to everyone); `cost_stats` reports the full USD cost breakdown and is `requires_admin` - administrators only. |
|
||||
| `chunks` | large results | `read_more` paginates any oversized tool result. |
|
||||
| `avatar` | on-screen | The `avatar_*` tools (md-clippy character) - web only. |
|
||||
| `client` | the browser | `get_page_context`, `run_js`, `highlight_element`, `clear_highlights`, `show_toast`, `scroll_to_element`, `navigate_to`, `reload_page` - run in the user's own browser; `run_js` is gated by the `devii_allow_eval` config. |
|
||||
@@ -31,10 +32,14 @@ account-operating tools are not even offered to a guest.
|
||||
## Auth scope
|
||||
|
||||
`requires_auth` defaults to true. The dispatcher refuses an auth-required action when the platform
|
||||
client is not authenticated. Beyond authentication, **role** enforcement is done by the platform
|
||||
endpoints themselves (for example the admin endpoints return 403 to non-admins) - Devii does not
|
||||
re-implement role checks for platform data. The local handlers (`avatar`, `client`, `agentic`,
|
||||
`task`, `docs`, `cost`, `chunks`) act only on the owner's own session, browser, or memory.
|
||||
client is not authenticated. `requires_admin` actions are gated twice: the model never receives
|
||||
their schema unless the session owner is an administrator, and the dispatcher independently rejects
|
||||
any `requires_admin` action for a non-admin owner. The owner's admin status is resolved server-side
|
||||
from the session/API-key user role (`is_admin(user)`), never from client input. Beyond this, **role**
|
||||
enforcement for platform data is also done by the platform endpoints themselves (for example the
|
||||
admin endpoints return 403 to non-admins). The local handlers (`avatar`, `client`, `agentic`,
|
||||
`task`, `docs`, `cost`, `chunks`) act only on the owner's own session, browser, or memory; financial
|
||||
cost figures are never disclosed to non-admins.
|
||||
|
||||
## The ReAct protocol
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/sidebar.css">
|
||||
<link rel="stylesheet" href="/static/css/docs.css">
|
||||
<link rel="stylesheet" href="/static/css/components-docs.css">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<script>window.DEVPLACE_DOCS = {{ {
|
||||
@@ -45,6 +46,8 @@
|
||||
{% include "docs/_search.html" %}
|
||||
{% elif kind == 'api' %}
|
||||
{% include "docs/_api_page.html" %}
|
||||
{% elif prose_html %}
|
||||
{{ prose_html | safe }}
|
||||
{% else %}
|
||||
{% include "docs/" ~ current ~ ".html" %}
|
||||
{% endif %}
|
||||
|
||||
@@ -145,8 +145,8 @@
|
||||
</div>
|
||||
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="post-image">Add an image (optional)</label>
|
||||
<input type="file" id="post-image" name="image" accept="image/*">
|
||||
<label>Add an image (optional)</label>
|
||||
<dp-upload mode="field" field-name="image" accept="image/*" label="Add image"></dp-upload>
|
||||
</div>
|
||||
|
||||
<div class="auth-field auth-field-gap">
|
||||
|
||||
@@ -54,10 +54,10 @@
|
||||
<form class="messages-input-area" method="POST" action="/messages/send">
|
||||
<input type="hidden" name="receiver_uid" value="{{ other_user['uid'] }}">
|
||||
<input type="text" name="content" placeholder="Type a message..." required maxlength="2000" autocomplete="off" autofocus data-mention>
|
||||
<div class="attachment-upload-container" data-attachment-upload
|
||||
data-max-size="{{ max_upload_size_mb() }}"
|
||||
data-max-files="5"
|
||||
data-allowed-types="{{ allowed_file_types() }}"></div>
|
||||
<dp-upload multiple
|
||||
max-size="{{ max_upload_size_mb() }}"
|
||||
max-files="5"
|
||||
allowed-types="{{ allowed_file_types() }}"></dp-upload>
|
||||
<button type="submit" class="messages-send-btn">➤</button>
|
||||
</form>
|
||||
{% else %}
|
||||
|
||||
@@ -188,6 +188,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_admin(user) %}
|
||||
<div class="ai-usage-card" data-ai-usage data-uid="{{ profile_user['uid'] }}">
|
||||
<div class="ai-usage-head">
|
||||
<h3 class="ai-usage-title">AI usage (last 24h)</h3>
|
||||
<span class="ai-usage-updated" data-ai-usage-updated></span>
|
||||
</div>
|
||||
{% if ai_quota %}
|
||||
<div class="ai-quota" data-ai-quota>
|
||||
<div class="ai-quota-bar">
|
||||
<span class="ai-quota-fill {% if ai_quota.used_pct >= 90 %}ai-quota-danger{% endif %}" style="width: {{ ai_quota.used_pct }}%"></span>
|
||||
</div>
|
||||
<div class="ai-quota-meta">
|
||||
<span class="ai-quota-pct">{{ ai_quota.used_pct }}% of daily quota used</span>
|
||||
<span class="ai-quota-detail">${{ '%.4f'|format(ai_quota.spent_usd) }} / ${{ '%.2f'|format(ai_quota.limit_usd) }} · {{ ai_quota.turns }} turns</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="ai-usage-body" data-ai-usage-body>
|
||||
<p class="ai-usage-empty">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif is_self(user, profile_user['uid']) %}
|
||||
<div class="ai-usage-card ai-quota-only">
|
||||
<div class="ai-usage-head">
|
||||
<h3 class="ai-usage-title">AI quota</h3>
|
||||
</div>
|
||||
{% if ai_quota %}
|
||||
<div class="ai-quota" data-ai-quota>
|
||||
<div class="ai-quota-bar">
|
||||
<span class="ai-quota-fill {% if ai_quota.used_pct >= 90 %}ai-quota-danger{% endif %}" style="width: {{ ai_quota.used_pct }}%"></span>
|
||||
</div>
|
||||
<div class="ai-quota-meta">
|
||||
<span class="ai-quota-pct">{{ ai_quota.used_pct }}% of your daily AI quota used</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="ai-usage-empty">Quota unavailable.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="profile-tabs">
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">📝</span> Posts</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="project-detail-actions">
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn">📁 Files</a>
|
||||
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}">🔗 Share</button>
|
||||
{% if user %}
|
||||
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/vendor/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/static/vendor/codemirror/theme/monokai.min.css">
|
||||
<link rel="stylesheet" href="/static/css/project_files.css">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="pf-page" id="project-files" data-slug="{{ project['slug'] or project['uid'] }}" data-owner="{{ 1 if is_owner else 0 }}">
|
||||
<div class="pf-header">
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}" class="back-link">← {{ project['title'] }}</a>
|
||||
<div class="pf-toolbar">
|
||||
{% if is_owner %}
|
||||
<button type="button" class="btn btn-secondary" id="pf-new-file">New file</button>
|
||||
<button type="button" class="btn btn-secondary" id="pf-new-folder">New folder</button>
|
||||
<button type="button" class="btn btn-secondary" id="pf-upload">Upload</button>
|
||||
<button type="button" class="btn btn-secondary" id="pf-rename">Rename</button>
|
||||
<button type="button" class="btn btn-secondary" id="pf-delete">Delete</button>
|
||||
<button type="button" class="btn btn-primary" id="pf-save">Save</button>
|
||||
{% endif %}
|
||||
<span class="pf-status" id="pf-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf-body">
|
||||
<aside class="pf-tree" id="pf-tree"></aside>
|
||||
<section class="pf-main">
|
||||
<div class="pf-current" id="pf-current"></div>
|
||||
<div class="pf-editor-wrap" id="pf-editor-wrap" hidden>
|
||||
<textarea id="project-file-editor" class="hidden-textarea"></textarea>
|
||||
</div>
|
||||
<div class="pf-preview" id="pf-preview" hidden></div>
|
||||
<div class="pf-empty" id="pf-empty">
|
||||
<div class="pf-empty-inner">
|
||||
<span class="pf-empty-icon">📂</span>
|
||||
<span class="pf-empty-msg">Select a file to view or edit.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<dp-upload id="pf-upload-widget" mode="direct" multiple field-name="file"
|
||||
endpoint="/projects/{{ project['slug'] or project['uid'] }}/files/upload" hidden></dp-upload>
|
||||
<script type="application/json" id="pf-data">{{ files | tojson }}</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script defer src="/static/vendor/codemirror/codemirror.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/python/python.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/javascript/javascript.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/clike/clike.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/htmlmixed/htmlmixed.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/xml/xml.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/css/css.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/go/go.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/sql/sql.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/shell/shell.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/yaml/yaml.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/markdown/markdown.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/swift/swift.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/php/php.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/ruby/ruby.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/haskell/haskell.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/lua/lua.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/perl/perl.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/r/r.min.js"></script>
|
||||
<script defer src="/static/vendor/codemirror/mode/dart/dart.min.js"></script>
|
||||
<script type="module">
|
||||
import { ProjectFiles } from "/static/js/ProjectFiles.js";
|
||||
const root = document.getElementById("project-files");
|
||||
if (root) {
|
||||
const files = new ProjectFiles(root);
|
||||
files.init();
|
||||
window.app.projectFiles = files;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user