feat: add backup CLI commands, AI correction/modifier services, and timezone-aware date display

- Add `devplace backups` CLI subcommands (list, run, prune, clear) with job enqueueing and orphan cleanup
- Introduce `BACKUPS_DIR` and `BACKUP_STAGING_DIR` config paths for backup storage
- Implement `schedule_correction` and `schedule_modification` calls in content creation, comment creation, and comment editing flows
- Add `DEFAULT_CORRECTION_PROMPT` and `DEFAULT_MODIFIER_PROMPT` config constants for AI content processing
- Document timezone-aware date display using `local_dt`/`dt_ago` Jinja globals with client-side `Intl` localization
- Update README with AI content correction/modifier support in direct messages via `@ai` inline instructions
- Add `track_action(user["uid"], "vote")` call on upvote in `apply_vote`
This commit is contained in:
2026-06-16 03:32:19 +00:00
parent e59bc2d34e
commit 15bd4ad87c
115 changed files with 5839 additions and 187 deletions
@@ -0,0 +1,68 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
AI_CORRECTION_ACTIONS: tuple[Action, ...] = (
Action(
name="ai_correction_get",
method="LOCAL",
path="",
summary="Show the user's AI content correction setting and prompt",
description=(
"Returns whether automatic AI content correction is enabled for the user and the "
"correction instruction in use. Use this before changing the setting."
),
handler="ai_correction",
requires_auth=True,
read_only=True,
),
Action(
name="ai_correction_set",
method="LOCAL",
path="",
summary="Enable or disable AI content correction and set its prompt",
description=(
"Turns automatic AI content correction on or off for the user. When enabled, prose the "
"user authors (posts, comments, projects, gists, direct messages, profile bio) is "
"rewritten using the supplied instruction and the user's own API key. By default the "
"correction runs in the background (applied just after saving); pass sync=true to apply it "
"synchronously so the save waits for the correction. "
"Pass 'prompt' to change the correction instruction; omit it to keep the current one."
),
handler="ai_correction",
requires_auth=True,
params=(
arg(
"enabled",
"true to enable automatic correction, false to disable it.",
required=True,
kind="boolean",
),
arg(
"sync",
"true to apply correction synchronously (the save waits), false for background. "
"Omit to keep the current mode.",
kind="boolean",
),
arg(
"prompt",
"The correction instruction (max 2000 chars). Omit to keep the current one.",
),
),
),
)
@@ -0,0 +1,71 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
AI_MODIFIER_ACTIONS: tuple[Action, ...] = (
Action(
name="ai_modifier_get",
method="LOCAL",
path="",
summary="Show the user's AI modifier setting and prompt",
description=(
"Returns whether the AI modifier is enabled for the user and the instruction in use. "
"The modifier runs only when authored text contains an inline '@ai <instruction>' "
"directive: it executes that instruction and replaces the marked part. Use this before "
"changing the setting."
),
handler="ai_modifier",
requires_auth=True,
read_only=True,
),
Action(
name="ai_modifier_set",
method="LOCAL",
path="",
summary="Enable or disable the AI modifier and set its prompt",
description=(
"Turns the AI modifier on or off for the user. When enabled, prose the user authors "
"(posts, comments, projects, gists, direct messages, profile bio) is rewritten using the "
"supplied instruction and the user's own API key, but ONLY where the text contains an "
"inline '@ai <instruction>' directive: that part is executed and the '@ai' marker removed. "
"By default the modification runs synchronously (the save waits); pass sync=false to apply "
"it in the background just after saving. Pass 'prompt' to change the instruction; omit it to "
"keep the current one."
),
handler="ai_modifier",
requires_auth=True,
params=(
arg(
"enabled",
"true to enable the AI modifier, false to disable it.",
required=True,
kind="boolean",
),
arg(
"sync",
"true to apply the modification synchronously (the save waits), false for background. "
"Omit to keep the current mode.",
kind="boolean",
),
arg(
"prompt",
"The modifier instruction (max 2000 chars). Omit to keep the current one.",
),
),
),
)
@@ -1415,6 +1415,96 @@ ACTIONS: tuple[Action, ...] = (
params=(confirm(),),
requires_admin=True,
),
Action(
name="backups_overview",
method="GET",
path="/admin/backups/data",
summary="Backup dashboard: storage usage, backups, and schedules (admin only)",
description=(
"Returns JSON: per-path storage usage (database, uploads, attachments, project files, "
"keys, zips, deepsearch, container workspaces, backups), total data-directory size, total "
"stored-backup size and count, disk usage (total/used/free/percent), every backup archive "
"(target, status, size, file count, sha256 checksum, created/completed time, download_url), "
"and every configured backup schedule. Use this for any question about backups, data "
"footprint, disk space, or what is scheduled."
),
requires_admin=True,
),
Action(
name="backup_run",
method="POST",
path="/admin/backups/run",
summary="Start a backup of a data target (admin only)",
description=(
"Enqueues an async backup job and returns its job uid and status_url. Target is one of: "
"database (consistent SQLite snapshot plus Devii databases), uploads (attachments and "
"project files), keys (VAPID keys and config), or full (database, uploads, and keys in one "
"archive). Poll backup_status with the returned uid until status is done, then read the "
"download_url. The job runs off the request path and never blocks the server."
),
params=(
body("target", "One of database, uploads, keys, full.", required=True),
),
requires_admin=True,
),
Action(
name="backup_status",
method="GET",
path="/admin/backups/jobs/{uid}",
summary="Check the status of a backup job (admin only)",
description=(
"Returns JSON for one backup job: status (pending, running, done, failed), target, "
"backup_uid, download_url (when done), sha256, archive size, file count, and timestamps. "
"Poll this after backup_run."
),
params=(path("uid", "Backup job uid returned by backup_run."),),
requires_admin=True,
),
Action(
name="backup_delete",
method="POST",
path="/admin/backups/{uid}/delete",
summary="Permanently delete a backup archive (admin only)",
description=(
"Removes a backup archive file and its record. This is irreversible and reclaims disk "
"space. The uid is a backup uid (from backups_overview), not a job uid."
),
params=(path("uid", "Backup uid to delete."), confirm()),
requires_admin=True,
),
Action(
name="backup_schedule_create",
method="POST",
path="/admin/backups/schedules/create",
summary="Create a recurring backup schedule (admin only)",
description=(
"Creates a schedule that fires backups automatically. kind is 'interval' (use every_seconds, "
"minimum 60) or 'cron' (use a 5-field cron expression in 'minute hour dom month dow'). "
"keep_last rotates older backups of this schedule, keeping only the newest N (0 keeps all). "
"target is one of database, uploads, keys, full."
),
params=(
body("name", "Human-readable schedule name.", required=True),
body("target", "One of database, uploads, keys, full.", required=True),
body("kind", "interval or cron.", required=True),
body("every_seconds", "Seconds between runs when kind=interval (min 60)."),
body("cron", "Cron expression when kind=cron, e.g. '0 3 * * *'."),
body("keep_last", "Keep only the newest N backups of this schedule (0 = all)."),
),
requires_admin=True,
),
Action(
name="backup_schedule_delete",
method="POST",
path="/admin/backups/schedules/{uid}/delete",
summary="Delete a backup schedule (admin only)",
description=(
"Removes a backup schedule so it stops firing. Existing backup archives are kept; only the "
"schedule is deleted."
),
params=(path("uid", "Backup schedule uid."), confirm()),
requires_admin=True,
),
Action(
name="restore_media",
method="POST",
@@ -50,6 +50,8 @@ CONFIRM_REQUIRED = {
"admin_reset_all_ai_quota",
"admin_reset_guest_ai_quota",
"admin_reset_user_ai_quota",
"backup_delete",
"backup_schedule_delete",
"notification_reset",
"db_insert_row",
"db_update_row",
@@ -285,6 +287,12 @@ class Dispatcher:
from ..notification import NotificationController
self._notification = NotificationController(owner_kind, owner_id)
from ..ai_correction import AiCorrectionController
self._ai_correction = AiCorrectionController(owner_kind, owner_id)
from ..ai_modifier import AiModifierController
self._ai_modifier = AiModifierController(owner_kind, owner_id)
self._virtual_tools = virtual_tools
self._behavior = behavior
self._read_files: set[tuple[str, str]] = set()
@@ -434,6 +442,12 @@ class Dispatcher:
if action.handler == "notification":
return await self._notification.dispatch(action.name, arguments)
if action.handler == "ai_correction":
return await self._ai_correction.dispatch(action.name, arguments)
if action.handler == "ai_modifier":
return await self._ai_modifier.dispatch(action.name, arguments)
if action.handler == "behavior":
if self._behavior is None:
return error_result(
@@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from .controller import AiCorrectionController
__all__ = ["AiCorrectionController"]
@@ -0,0 +1,94 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
from typing import Any
from devplacepy.config import DEFAULT_CORRECTION_PROMPT
from devplacepy.database import get_table
from devplacepy.services.devii.errors import ToolInputError
logger = logging.getLogger("devii.ai_correction")
class AiCorrectionController:
def __init__(self, owner_kind: str, owner_id: str) -> None:
self._owner_kind = owner_kind
self._owner_id = owner_id
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
if name == "ai_correction_get":
return self._get()
if name == "ai_correction_set":
return self._set(arguments)
raise ToolInputError(f"Unknown AI correction tool: {name}")
def _require_user(self) -> None:
if self._owner_kind != "user":
raise ToolInputError(
"AI content correction is only available for signed-in users."
)
def _coerce_bool(self, raw: Any) -> bool:
if isinstance(raw, bool):
return raw
return str(raw).strip().lower() in ("true", "1", "yes", "on")
def _value(self, arguments: dict[str, Any]) -> bool:
return self._coerce_bool(arguments.get("enabled"))
def _user(self) -> dict[str, Any]:
user = get_table("users").find_one(uid=self._owner_id)
if not user:
raise ToolInputError("User not found.")
return user
def _get(self) -> str:
self._require_user()
user = self._user()
return json.dumps(
{
"status": "success",
"enabled": bool(user.get("ai_correction_enabled")),
"sync": bool(user.get("ai_correction_sync")),
"prompt": user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT,
},
ensure_ascii=False,
)
def _set(self, arguments: dict[str, Any]) -> str:
self._require_user()
user = self._user()
enabled = 1 if self._value(arguments) else 0
if "sync" in arguments and arguments.get("sync") is not None:
sync = 1 if self._coerce_bool(arguments.get("sync")) else 0
else:
sync = 1 if user.get("ai_correction_sync") else 0
prompt_raw = arguments.get("prompt")
if prompt_raw is None:
prompt = user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT
else:
prompt = str(prompt_raw).strip()[:2000] or DEFAULT_CORRECTION_PROMPT
get_table("users").update(
{
"uid": self._owner_id,
"ai_correction_enabled": enabled,
"ai_correction_sync": sync,
"ai_correction_prompt": prompt,
},
["uid"],
)
from devplacepy.utils import clear_user_cache
clear_user_cache(self._owner_id)
return json.dumps(
{
"status": "success",
"enabled": bool(enabled),
"sync": bool(sync),
"prompt": prompt,
},
ensure_ascii=False,
)
@@ -0,0 +1,5 @@
# retoor <retoor@molodetz.nl>
from .controller import AiModifierController
__all__ = ["AiModifierController"]
@@ -0,0 +1,94 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import logging
from typing import Any
from devplacepy.config import DEFAULT_MODIFIER_PROMPT
from devplacepy.database import get_table
from devplacepy.services.devii.errors import ToolInputError
logger = logging.getLogger("devii.ai_modifier")
class AiModifierController:
def __init__(self, owner_kind: str, owner_id: str) -> None:
self._owner_kind = owner_kind
self._owner_id = owner_id
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
if name == "ai_modifier_get":
return self._get()
if name == "ai_modifier_set":
return self._set(arguments)
raise ToolInputError(f"Unknown AI modifier tool: {name}")
def _require_user(self) -> None:
if self._owner_kind != "user":
raise ToolInputError(
"AI modifier is only available for signed-in users."
)
def _coerce_bool(self, raw: Any) -> bool:
if isinstance(raw, bool):
return raw
return str(raw).strip().lower() in ("true", "1", "yes", "on")
def _value(self, arguments: dict[str, Any]) -> bool:
return self._coerce_bool(arguments.get("enabled"))
def _user(self) -> dict[str, Any]:
user = get_table("users").find_one(uid=self._owner_id)
if not user:
raise ToolInputError("User not found.")
return user
def _get(self) -> str:
self._require_user()
user = self._user()
return json.dumps(
{
"status": "success",
"enabled": bool(user.get("ai_modifier_enabled")),
"sync": bool(user.get("ai_modifier_sync")),
"prompt": user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT,
},
ensure_ascii=False,
)
def _set(self, arguments: dict[str, Any]) -> str:
self._require_user()
user = self._user()
enabled = 1 if self._value(arguments) else 0
if "sync" in arguments and arguments.get("sync") is not None:
sync = 1 if self._coerce_bool(arguments.get("sync")) else 0
else:
sync = 1 if user.get("ai_modifier_sync") else 0
prompt_raw = arguments.get("prompt")
if prompt_raw is None:
prompt = user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT
else:
prompt = str(prompt_raw).strip()[:2000] or DEFAULT_MODIFIER_PROMPT
get_table("users").update(
{
"uid": self._owner_id,
"ai_modifier_enabled": enabled,
"ai_modifier_sync": sync,
"ai_modifier_prompt": prompt,
},
["uid"],
)
from devplacepy.utils import clear_user_cache
clear_user_cache(self._owner_id)
return json.dumps(
{
"status": "success",
"enabled": bool(enabled),
"sync": bool(sync),
"prompt": prompt,
},
ensure_ascii=False,
)
+4
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from .actions.ai_correction_actions import AI_CORRECTION_ACTIONS
from .actions.ai_modifier_actions import AI_MODIFIER_ACTIONS
from .actions.avatar_actions import AVATAR_ACTIONS
from .actions.behavior_actions import BEHAVIOR_ACTIONS
from .actions.catalog import ACTIONS
@@ -34,6 +36,8 @@ CATALOG = Catalog(
+ CUSTOMIZATION_ACTIONS
+ BEHAVIOR_ACTIONS
+ NOTIFICATION_ACTIONS
+ AI_CORRECTION_ACTIONS
+ AI_MODIFIER_ACTIONS
+ VIRTUAL_TOOL_ACTIONS
)
+4
View File
@@ -401,6 +401,10 @@ class DeviiSession:
finally:
if not cancelled and epoch == self._turn_epoch:
self._record_turn(turn_id, started_at, text, reply, error, before)
if self.owner_kind == "user" and self.channel != "docs" and not error:
from devplacepy.utils import track_action
track_action(self.owner_id, "devii")
def _builtin_tools(self) -> list[dict[str, Any]]:
schemas = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)