forked from retoor/devplacepy
feat: add api key auth, devii agent, openai gateway, and admin service management
This commit introduces a comprehensive set of new features including API key authentication with CLI management commands (get, reset, backfill), a Devii agentic assistant with WebSocket terminal and session bootstrap, an OpenAI-compatible LLM gateway service, and an admin service management panel. It also adds Playwright browser automation for bot support, configures internal gateway URLs, refactors content editing/deletion to support JSON API responses, and updates documentation across AGENTS.md, README.md, and the developer docs site.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .catalog import ACTIONS, PLATFORM_CATALOG
|
||||
from .dispatcher import Dispatcher
|
||||
from .spec import Action, Catalog, Param
|
||||
|
||||
__all__ = ["ACTIONS", "PLATFORM_CATALOG", "Dispatcher", "Action", "Catalog", "Param"]
|
||||
@@ -0,0 +1,149 @@
|
||||
# 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)
|
||||
|
||||
|
||||
DEVII = (
|
||||
"Controls devii, the on-screen avatar that is you rendered as a classic animated "
|
||||
"desktop character. Only effective in the web interface; in other interfaces it reports "
|
||||
"that no avatar is attached."
|
||||
)
|
||||
|
||||
AVATAR_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="avatar_show",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Show devii (yourself) on screen",
|
||||
description=DEVII,
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="avatar_hide",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Hide devii from the screen (plays a goodbye/hide animation)",
|
||||
description=(
|
||||
DEVII + " This already plays a goodbye animation, so do not queue a separate "
|
||||
"wave/goodbye animation immediately before calling it - hiding interrupts a still-"
|
||||
"queued animation."
|
||||
),
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="avatar_speak",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Show a line of text in devii's speech balloon",
|
||||
description=DEVII,
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("text", "The text devii should say.", required=True),
|
||||
arg("hold", "Keep the balloon open until the next action.", kind="boolean"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="avatar_list_animations",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="List every animation the current character supports",
|
||||
description=DEVII,
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="avatar_play_animation",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Play a specific named animation (use avatar_list_animations first)",
|
||||
description=(
|
||||
DEVII + " A following avatar_hide or avatar_stop interrupts an animation that is "
|
||||
"still playing, so do not hide immediately after if you want it to finish."
|
||||
),
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
params=(arg("name", "Exact animation name.", required=True),),
|
||||
),
|
||||
Action(
|
||||
name="avatar_random_animation",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Play a random non-idle animation to react expressively",
|
||||
description=DEVII,
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="avatar_move_to",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Walk devii to a pixel position on screen",
|
||||
description=DEVII,
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("x", "Target x in pixels.", required=True, kind="integer"),
|
||||
arg("y", "Target y in pixels.", required=True, kind="integer"),
|
||||
arg("duration", "Movement duration in milliseconds.", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="avatar_gesture_at",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Make devii gesture toward a pixel position",
|
||||
description=DEVII,
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("x", "Target x in pixels.", required=True, kind="integer"),
|
||||
arg("y", "Target y in pixels.", required=True, kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="avatar_get_viewport",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Get the screen size and devii's current position",
|
||||
description=DEVII,
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="avatar_stop",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Stop all of devii's queued animations and speech immediately",
|
||||
description=DEVII,
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="avatar_list_characters",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="List every character devii can appear as",
|
||||
description=DEVII,
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="avatar_switch_character",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Change which character devii is rendered as",
|
||||
description=DEVII,
|
||||
handler="avatar",
|
||||
requires_auth=False,
|
||||
params=(arg("name", "Character name (e.g. Clippy, Merlin, Bonzi, Genie, Rover).", required=True),),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,692 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Catalog, Param
|
||||
|
||||
|
||||
def path(name: str, description: str, required: bool = True) -> Param:
|
||||
return Param(name=name, location="path", description=description, required=required)
|
||||
|
||||
|
||||
def query(name: str, description: str, required: bool = False) -> Param:
|
||||
return Param(name=name, location="query", description=description, required=required)
|
||||
|
||||
|
||||
def body(name: str, description: str, required: bool = False) -> Param:
|
||||
return Param(name=name, location="body", description=description, required=required)
|
||||
|
||||
|
||||
def upload(name: str, description: str) -> Param:
|
||||
return Param(name=name, location="file", description=description, required=True)
|
||||
|
||||
|
||||
ATTACHMENTS = "Comma separated attachment uids returned by upload_file."
|
||||
TARGET_TYPE = "Target type, e.g. post, comment, project, gist."
|
||||
|
||||
ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="auth_status",
|
||||
method="GET",
|
||||
path="",
|
||||
summary="Report whether the current session is authenticated",
|
||||
handler="status",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="login",
|
||||
method="POST",
|
||||
path="/auth/login",
|
||||
summary="Authenticate with email and password and start a session",
|
||||
handler="login",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
body("email", "Account email address.", required=True),
|
||||
body("password", "Account password.", required=True),
|
||||
body("remember_me", "Keep the session alive longer ('on' or '')."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="logout",
|
||||
method="GET",
|
||||
path="/auth/logout",
|
||||
summary="End the current session",
|
||||
handler="logout",
|
||||
requires_auth=True,
|
||||
),
|
||||
Action(
|
||||
name="signup",
|
||||
method="POST",
|
||||
path="/auth/signup",
|
||||
summary="Create a new account",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
body("username", "Desired username.", required=True),
|
||||
body("email", "Email address.", required=True),
|
||||
body("password", "Password (minimum six characters).", required=True),
|
||||
body("confirm_password", "Password confirmation.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="forgot_password",
|
||||
method="POST",
|
||||
path="/auth/forgot-password",
|
||||
summary="Request a password reset email",
|
||||
requires_auth=False,
|
||||
params=(body("email", "Account email address.", required=True),),
|
||||
),
|
||||
Action(
|
||||
name="reset_password",
|
||||
method="POST",
|
||||
path="/auth/reset-password/{token}",
|
||||
summary="Reset a password using a reset token",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
path("token", "Reset token from the email link."),
|
||||
body("password", "New password.", required=True),
|
||||
body("confirm_password", "New password confirmation.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="view_feed",
|
||||
method="GET",
|
||||
path="/feed",
|
||||
summary="View the activity feed",
|
||||
params=(
|
||||
query("tab", "Feed tab to view."),
|
||||
query("topic", "Filter by topic."),
|
||||
query("before", "Pagination cursor."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="create_post",
|
||||
method="POST",
|
||||
path="/posts/create",
|
||||
summary="Create a new post",
|
||||
params=(
|
||||
body("content", "Post body text.", required=True),
|
||||
body("title", "Optional post title."),
|
||||
body("topic", "Optional topic."),
|
||||
body("project_uid", "Attach the post to a project uid."),
|
||||
body("attachment_uids", ATTACHMENTS),
|
||||
body("poll_question", "Optional poll question."),
|
||||
body("poll_options", "Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="view_post",
|
||||
method="GET",
|
||||
path="/posts/{post_slug}",
|
||||
summary="View a single post by slug",
|
||||
params=(path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),),
|
||||
),
|
||||
Action(
|
||||
name="edit_post",
|
||||
method="POST",
|
||||
path="/posts/edit/{post_slug}",
|
||||
summary="Edit an existing post",
|
||||
params=(
|
||||
path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),
|
||||
body("content", "Updated post body.", required=True),
|
||||
body("title", "Updated title."),
|
||||
body("topic", "Updated topic."),
|
||||
body("poll_question", "Optional poll question. Adds a poll to a post that does not already have one."),
|
||||
body("poll_options", "Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_post",
|
||||
method="POST",
|
||||
path="/posts/delete/{post_slug}",
|
||||
summary="Delete a post",
|
||||
params=(path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),),
|
||||
),
|
||||
Action(
|
||||
name="create_comment",
|
||||
method="POST",
|
||||
path="/comments/create",
|
||||
summary="Create a comment on a post or other target",
|
||||
params=(
|
||||
body("content", "Comment body.", required=True),
|
||||
body("post_uid", "Uid of the post being commented on."),
|
||||
body("target_uid", "Uid of the target when not a post."),
|
||||
body("target_type", TARGET_TYPE),
|
||||
body("parent_uid", "Parent comment uid for replies."),
|
||||
body("attachment_uids", ATTACHMENTS),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_comment",
|
||||
method="POST",
|
||||
path="/comments/delete/{comment_uid}",
|
||||
summary="Delete a comment",
|
||||
params=(path("comment_uid", "Uid of the comment."),),
|
||||
),
|
||||
Action(
|
||||
name="list_projects",
|
||||
method="GET",
|
||||
path="/projects",
|
||||
summary="List projects",
|
||||
params=(
|
||||
query("tab", "Projects tab."),
|
||||
query("search", "Search query."),
|
||||
query("user_uid", "Filter by owner uid."),
|
||||
query("project_type", "Filter by project type."),
|
||||
query("before", "Pagination cursor."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="view_project",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}",
|
||||
summary="View a project by slug",
|
||||
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="create_project",
|
||||
method="POST",
|
||||
path="/projects/create",
|
||||
summary="Create a new project",
|
||||
params=(
|
||||
body("title", "Project title.", required=True),
|
||||
body("description", "Project description.", required=True),
|
||||
body("release_date", "Release date."),
|
||||
body("demo_date", "Demo date."),
|
||||
body("project_type", "Project type."),
|
||||
body("platforms", "Supported platforms."),
|
||||
body("status", "Project status."),
|
||||
body("attachment_uids", ATTACHMENTS),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_project",
|
||||
method="POST",
|
||||
path="/projects/delete/{project_slug}",
|
||||
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="search_users",
|
||||
method="GET",
|
||||
path="/profile/search",
|
||||
summary="Search for users by name",
|
||||
params=(query("q", "Search query."),),
|
||||
),
|
||||
Action(
|
||||
name="view_profile",
|
||||
method="GET",
|
||||
path="/profile/{username}",
|
||||
summary="View a user profile",
|
||||
params=(
|
||||
path("username", "Username to view."),
|
||||
query("tab", "Profile tab."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="update_profile",
|
||||
method="POST",
|
||||
path="/profile/update",
|
||||
summary="Update the current user's profile",
|
||||
params=(
|
||||
body("bio", "Profile biography."),
|
||||
body("location", "Location."),
|
||||
body("git_link", "Git profile link."),
|
||||
body("website", "Personal website."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="regenerate_api_key",
|
||||
method="POST",
|
||||
path="/profile/regenerate-api-key",
|
||||
summary="Issue a new API key and invalidate the current one",
|
||||
description=(
|
||||
"Returns the new api_key. Warning: this immediately invalidates any key currently "
|
||||
"used for authentication, so confirm with the user before calling it."
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="list_messages",
|
||||
method="GET",
|
||||
path="/messages",
|
||||
summary="View direct message conversations",
|
||||
params=(
|
||||
query("with_uid", "Open a conversation with a user uid."),
|
||||
query("search", "Search conversations."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="search_message_users",
|
||||
method="GET",
|
||||
path="/messages/search",
|
||||
summary="Search users to message",
|
||||
params=(query("q", "Search query."),),
|
||||
),
|
||||
Action(
|
||||
name="send_message",
|
||||
method="POST",
|
||||
path="/messages/send",
|
||||
summary="Send a direct message",
|
||||
params=(
|
||||
body("content", "Message body.", required=True),
|
||||
body("receiver_uid", "Recipient user uid.", required=True),
|
||||
body("attachment_uids", ATTACHMENTS),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="list_notifications",
|
||||
method="GET",
|
||||
path="/notifications",
|
||||
summary="List notifications",
|
||||
params=(query("before", "Pagination cursor."),),
|
||||
),
|
||||
Action(
|
||||
name="notification_counts",
|
||||
method="GET",
|
||||
path="/notifications/counts",
|
||||
summary="Get unread notification and message counts",
|
||||
),
|
||||
Action(
|
||||
name="open_notification",
|
||||
method="GET",
|
||||
path="/notifications/open/{notification_uid}",
|
||||
summary="Open a notification and follow it",
|
||||
params=(path("notification_uid", "Notification uid."),),
|
||||
),
|
||||
Action(
|
||||
name="mark_notification_read",
|
||||
method="POST",
|
||||
path="/notifications/mark-read/{notification_uid}",
|
||||
summary="Mark a single notification read",
|
||||
params=(path("notification_uid", "Notification uid."),),
|
||||
),
|
||||
Action(
|
||||
name="mark_all_notifications_read",
|
||||
method="POST",
|
||||
path="/notifications/mark-all-read",
|
||||
summary="Mark all notifications read",
|
||||
),
|
||||
Action(
|
||||
name="vote",
|
||||
method="POST",
|
||||
path="/votes/{target_type}/{target_uid}",
|
||||
summary="Cast or toggle a vote on a target",
|
||||
description="Re-sending the same value removes the vote. Returns the net/up/down tally.",
|
||||
ajax=True,
|
||||
params=(
|
||||
path("target_type", TARGET_TYPE),
|
||||
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
|
||||
body("value", "Vote value: 1 to upvote, -1 to downvote (re-send to remove).", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="react",
|
||||
method="POST",
|
||||
path="/reactions/{target_type}/{target_uid}",
|
||||
summary="Toggle an emoji reaction on a target",
|
||||
description="Re-sending the same emoji removes it. Returns reaction counts.",
|
||||
ajax=True,
|
||||
params=(
|
||||
path("target_type", TARGET_TYPE),
|
||||
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
|
||||
body("emoji", "One of the allowed reaction emoji.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="list_bookmarks",
|
||||
method="GET",
|
||||
path="/bookmarks/saved",
|
||||
summary="List saved bookmarks",
|
||||
params=(query("before", "Pagination cursor."),),
|
||||
),
|
||||
Action(
|
||||
name="toggle_bookmark",
|
||||
method="POST",
|
||||
path="/bookmarks/{target_type}/{target_uid}",
|
||||
summary="Toggle a bookmark on a target",
|
||||
description="Re-sending removes the bookmark. Returns {saved: true|false}.",
|
||||
ajax=True,
|
||||
params=(
|
||||
path("target_type", TARGET_TYPE),
|
||||
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="vote_poll",
|
||||
method="POST",
|
||||
path="/polls/{poll_uid}/vote",
|
||||
summary="Vote in a poll",
|
||||
description="Casts or changes your vote on a poll option. Returns the option tallies.",
|
||||
ajax=True,
|
||||
params=(
|
||||
path("poll_uid", "Poll uid."),
|
||||
body("option_uid", "Chosen option uid.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="follow_user",
|
||||
method="POST",
|
||||
path="/follow/{username}",
|
||||
summary="Follow a user",
|
||||
params=(path("username", "Username to follow."),),
|
||||
),
|
||||
Action(
|
||||
name="unfollow_user",
|
||||
method="POST",
|
||||
path="/follow/unfollow/{username}",
|
||||
summary="Unfollow a user",
|
||||
params=(path("username", "Username to unfollow."),),
|
||||
),
|
||||
Action(
|
||||
name="list_followers",
|
||||
method="GET",
|
||||
path="/profile/{username}/followers",
|
||||
summary="List the users who follow a profile",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
path("username", "Username whose followers to list."),
|
||||
query("page", "Page number, 25 per page."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="list_following",
|
||||
method="GET",
|
||||
path="/profile/{username}/following",
|
||||
summary="List the users a profile follows",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
path("username", "Username whose following to list."),
|
||||
query("page", "Page number, 25 per page."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="view_leaderboard",
|
||||
method="GET",
|
||||
path="/leaderboard",
|
||||
summary="View the leaderboard",
|
||||
),
|
||||
Action(
|
||||
name="list_bugs",
|
||||
method="GET",
|
||||
path="/bugs",
|
||||
summary="List reported bugs",
|
||||
),
|
||||
Action(
|
||||
name="create_bug",
|
||||
method="POST",
|
||||
path="/bugs/create",
|
||||
summary="Report a bug",
|
||||
params=(
|
||||
body("title", "Bug title.", required=True),
|
||||
body("description", "Bug description.", required=True),
|
||||
body("attachment_uids", ATTACHMENTS),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="list_gists",
|
||||
method="GET",
|
||||
path="/gists",
|
||||
summary="List gists",
|
||||
params=(
|
||||
query("language", "Filter by language."),
|
||||
query("user_uid", "Filter by owner uid."),
|
||||
query("before", "Pagination cursor."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="view_gist",
|
||||
method="GET",
|
||||
path="/gists/{gist_slug}",
|
||||
summary="View a gist by slug",
|
||||
params=(path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),),
|
||||
),
|
||||
Action(
|
||||
name="create_gist",
|
||||
method="POST",
|
||||
path="/gists/create",
|
||||
summary="Create a gist",
|
||||
params=(
|
||||
body("title", "Gist title.", required=True),
|
||||
body("source_code", "Gist source code.", required=True),
|
||||
body("description", "Gist description."),
|
||||
body("language", "Programming language."),
|
||||
body("attachment_uids", ATTACHMENTS),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="edit_gist",
|
||||
method="POST",
|
||||
path="/gists/edit/{gist_slug}",
|
||||
summary="Edit a gist",
|
||||
params=(
|
||||
path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),
|
||||
body("title", "Gist title.", required=True),
|
||||
body("source_code", "Gist source code.", required=True),
|
||||
body("description", "Gist description."),
|
||||
body("language", "Programming language."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_gist",
|
||||
method="POST",
|
||||
path="/gists/delete/{gist_slug}",
|
||||
summary="Delete a gist",
|
||||
params=(path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),),
|
||||
),
|
||||
Action(
|
||||
name="list_news",
|
||||
method="GET",
|
||||
path="/news",
|
||||
summary="List news articles",
|
||||
params=(query("before", "Pagination cursor."),),
|
||||
),
|
||||
Action(
|
||||
name="view_news",
|
||||
method="GET",
|
||||
path="/news/{news_slug}",
|
||||
summary="View a news article by slug",
|
||||
params=(path("news_slug", "Exact news slug copied from a /news/... link in a listing response; do not build it from the title."),),
|
||||
),
|
||||
Action(
|
||||
name="upload_file",
|
||||
method="POST",
|
||||
path="/uploads/upload",
|
||||
summary="Upload a local file and obtain its attachment uid",
|
||||
params=(upload("file", "Local filesystem path of the file to upload."),),
|
||||
),
|
||||
Action(
|
||||
name="delete_attachment",
|
||||
method="DELETE",
|
||||
path="/uploads/delete/{attachment_uid}",
|
||||
summary="Delete an uploaded attachment",
|
||||
params=(path("attachment_uid", "Uid of the attachment."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_overview",
|
||||
method="GET",
|
||||
path="/admin",
|
||||
summary="View the admin overview",
|
||||
),
|
||||
Action(
|
||||
name="site_analytics",
|
||||
method="GET",
|
||||
path="/admin/analytics",
|
||||
summary="Site-wide aggregate analytics in one call (admin only)",
|
||||
description=(
|
||||
"Returns JSON: total members, active users in the last 24h/7d/30d, users signed in "
|
||||
"now, new signups (24h/7d/30d), content totals (posts, comments, gists, projects, "
|
||||
"news), and top authors. Use this for any 'how many'/'how active'/count question "
|
||||
"instead of paging through admin_list_users."
|
||||
),
|
||||
params=(query("top_n", "How many top authors to include (1-50)."),),
|
||||
),
|
||||
Action(
|
||||
name="ai_usage",
|
||||
method="GET",
|
||||
path="/admin/ai-usage/data",
|
||||
summary="AI gateway usage, cost, latency, and reliability metrics (admin only)",
|
||||
description=(
|
||||
"Returns JSON for a bounded window: request volume and throughput, token usage with "
|
||||
"averages and percentiles, latency (upstream, gateway overhead, queue wait, connect), "
|
||||
"error rates by category, cost in USD (per model, per caller, input vs output, projected "
|
||||
"monthly burn, caching savings), caller behavior, and an hourly breakdown. Use this for "
|
||||
"any question about AI spend, token consumption, gateway performance, or errors."
|
||||
),
|
||||
params=(
|
||||
query("hours", "Lookback window in hours (1-168, default 48)."),
|
||||
query("top_n", "How many rows in each top-N breakdown (default 10)."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="admin_list_users",
|
||||
method="GET",
|
||||
path="/admin/users",
|
||||
summary="List users for administration",
|
||||
params=(query("page", "Page number."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_set_user_role",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/role",
|
||||
summary="Set a user's role",
|
||||
params=(
|
||||
path("uid", "User uid."),
|
||||
body("role", "New role.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="admin_set_user_password",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/password",
|
||||
summary="Set a user's password",
|
||||
params=(
|
||||
path("uid", "User uid."),
|
||||
body("password", "New password.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="admin_toggle_user",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/toggle",
|
||||
summary="Toggle a user's active state",
|
||||
params=(path("uid", "User uid."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_get_settings",
|
||||
method="GET",
|
||||
path="/admin/settings",
|
||||
summary="View site settings",
|
||||
),
|
||||
Action(
|
||||
name="admin_save_settings",
|
||||
method="POST",
|
||||
path="/admin/settings",
|
||||
summary="Save site settings",
|
||||
params=(
|
||||
body("site_name", "Site name."),
|
||||
body("site_description", "Site description."),
|
||||
body("site_tagline", "Site tagline."),
|
||||
body("max_upload_size_mb", "Maximum upload size in megabytes."),
|
||||
body("allowed_file_types", "Allowed file types."),
|
||||
body("max_attachments_per_resource", "Maximum attachments per resource."),
|
||||
body("rate_limit_per_minute", "Rate limit per minute."),
|
||||
body("rate_limit_window_seconds", "Rate limit window in seconds."),
|
||||
body("session_max_age_days", "Session maximum age in days."),
|
||||
body("session_remember_days", "Remember-me duration in days."),
|
||||
body("registration_open", "Whether registration is open."),
|
||||
body("maintenance_mode", "Whether maintenance mode is on."),
|
||||
body("maintenance_message", "Maintenance message."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="admin_list_news",
|
||||
method="GET",
|
||||
path="/admin/news",
|
||||
summary="List news for administration",
|
||||
params=(query("page", "Page number."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_toggle_news",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/toggle",
|
||||
summary="Toggle a news article",
|
||||
params=(path("uid", "News uid."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_publish_news",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/publish",
|
||||
summary="Publish a news article",
|
||||
params=(path("uid", "News uid."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_landing_news",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/landing",
|
||||
summary="Set a news article as landing content",
|
||||
params=(path("uid", "News uid."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_delete_news",
|
||||
method="POST",
|
||||
path="/admin/news/{uid}/delete",
|
||||
summary="Delete a news article",
|
||||
params=(path("uid", "News uid."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_list_services",
|
||||
method="GET",
|
||||
path="/admin/services",
|
||||
summary="View managed services",
|
||||
),
|
||||
Action(
|
||||
name="admin_services_data",
|
||||
method="GET",
|
||||
path="/admin/services/data",
|
||||
summary="Get live status, metrics, and log tail for every background service",
|
||||
),
|
||||
Action(
|
||||
name="admin_service_status",
|
||||
method="GET",
|
||||
path="/admin/services/{name}/data",
|
||||
summary="Get live status, metrics, and log tail for one background service",
|
||||
params=(path("name", "Service name (e.g. news, bots, openai)."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_start_service",
|
||||
method="POST",
|
||||
path="/admin/services/{name}/start",
|
||||
summary="Start a managed service",
|
||||
params=(path("name", "Service name."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_stop_service",
|
||||
method="POST",
|
||||
path="/admin/services/{name}/stop",
|
||||
summary="Stop a managed service",
|
||||
params=(path("name", "Service name."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_run_service",
|
||||
method="POST",
|
||||
path="/admin/services/{name}/run",
|
||||
summary="Run a managed service once",
|
||||
params=(path("name", "Service name."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_clear_service_logs",
|
||||
method="POST",
|
||||
path="/admin/services/{name}/clear-logs",
|
||||
summary="Clear a managed service's logs",
|
||||
params=(path("name", "Service name."),),
|
||||
),
|
||||
Action(
|
||||
name="admin_config_service",
|
||||
method="POST",
|
||||
path="/admin/services/{name}/config",
|
||||
summary="Update a managed service's configuration",
|
||||
params=(path("name", "Service name."),),
|
||||
freeform_body=True,
|
||||
),
|
||||
)
|
||||
|
||||
PLATFORM_CATALOG = Catalog(actions=ACTIONS)
|
||||
@@ -0,0 +1,41 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
CHUNK_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="read_more",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Read the next part of a previously truncated tool result",
|
||||
description=(
|
||||
"When any tool result is truncated it includes a chunk_id, total_chars, "
|
||||
"remaining_chars, and next_offset. Call read_more with that chunk_id and offset to "
|
||||
"page through the rest until remaining_chars is 0, so you can read the whole content."
|
||||
),
|
||||
handler="chunks",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
Param(
|
||||
name="chunk_id",
|
||||
location="body",
|
||||
description="The chunk_id from a truncated result.",
|
||||
required=True,
|
||||
),
|
||||
Param(
|
||||
name="offset",
|
||||
location="body",
|
||||
description="Character offset to read from (use next_offset from the prior part).",
|
||||
type="integer",
|
||||
),
|
||||
Param(
|
||||
name="length",
|
||||
location="body",
|
||||
description="Optional number of characters to return (capped to the response limit).",
|
||||
type="integer",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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)
|
||||
|
||||
|
||||
CLIENT = (
|
||||
"Runs in the user's own browser through the web terminal. Only effective in the web "
|
||||
"interface with a live connection; elsewhere it reports that no browser is attached."
|
||||
)
|
||||
|
||||
CLIENT_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="get_page_context",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Read the user's current page: URL, title, viewport, scroll, selected text, visible headings, and whether they are signed in",
|
||||
description=CLIENT + " Use this to understand where the user is and what they are looking at before acting or guiding them.",
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="run_js",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Execute JavaScript in the user's browser and return its result",
|
||||
description=(
|
||||
CLIENT + " The code is the body of an async function; use 'return value' to return a "
|
||||
"JSON-serializable result. You have full access to window and document. Use this for "
|
||||
"anything not covered by the dedicated tools: read or change the DOM, drive a live "
|
||||
"demo, inspect state, or update the screen. Prefer the dedicated tools "
|
||||
"(highlight_element, show_toast, scroll_to_element, navigate_to, reload_page) when they fit."
|
||||
),
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("code", "JavaScript to run as an async function body. Return a JSON-serializable value.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="highlight_element",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Highlight an element on screen with an outline and an optional callout label, for live tutorials",
|
||||
description=CLIENT + " Scrolls the element into view and draws an attention outline. Call clear_highlights to remove it.",
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("selector", "CSS selector, or the element's exact visible text (e.g. a heading or link label).", required=True),
|
||||
arg("label", "Optional callout text shown next to the element."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="clear_highlights",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Remove all highlights and callouts placed by highlight_element",
|
||||
description=CLIENT,
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="show_toast",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Show a brief on-screen message (toast) to the user",
|
||||
description=CLIENT,
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("text", "Message to display.", required=True),
|
||||
arg("duration_ms", "How long to show it, in milliseconds (default 4000).", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="scroll_to_element",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Smoothly scroll an element into view",
|
||||
description=CLIENT,
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
params=(arg("selector", "CSS selector, or the element's exact visible text.", required=True),),
|
||||
),
|
||||
Action(
|
||||
name="navigate_to",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Send the user's browser to a URL",
|
||||
description=(
|
||||
CLIENT + " Use a same-origin path like /feed or /docs/index.html, or a full URL. The "
|
||||
"page reloads; the user's Devii session and conversation persist and reconnect automatically."
|
||||
),
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
params=(arg("url", "Path or URL to navigate to.", required=True),),
|
||||
),
|
||||
Action(
|
||||
name="reload_page",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Refresh the user's current page, e.g. after something changed",
|
||||
description=CLIENT + " The Devii session and conversation persist and reconnect automatically.",
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action
|
||||
|
||||
COST_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="cost_stats",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Report token usage and cost statistics for the current session",
|
||||
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."
|
||||
),
|
||||
handler="cost",
|
||||
requires_auth=False,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,223 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from ..config import Settings
|
||||
from ..errors import (
|
||||
AuthRequiredError,
|
||||
DeviiError,
|
||||
ToolInputError,
|
||||
error_result,
|
||||
unexpected_result,
|
||||
)
|
||||
from ..agentic.controller import AgenticController
|
||||
from ..agentic.state import record_mutation
|
||||
from ..avatar import AvatarController
|
||||
from ..chunks import ChunkController, get_store, serve_resource, wrap_if_large
|
||||
from ..cost import CostController
|
||||
from ..docs import DocsController
|
||||
from ..fetch import FetchController
|
||||
from ..http_client import PlatformClient
|
||||
from ..rsearch import RsearchController
|
||||
from ..tasks.controller import TaskController
|
||||
from ..text import format_response
|
||||
from .spec import Action, Catalog
|
||||
|
||||
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
|
||||
|
||||
logger = logging.getLogger("devii.dispatch")
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
def __init__(
|
||||
self,
|
||||
catalog: Catalog,
|
||||
client: PlatformClient,
|
||||
settings: Settings,
|
||||
tasks: TaskController,
|
||||
agentic: AgenticController,
|
||||
avatar: AvatarController | None = None,
|
||||
browser: Any = None,
|
||||
) -> None:
|
||||
self._actions = catalog.by_name()
|
||||
self._client = client
|
||||
self._settings = settings
|
||||
self._tasks = tasks
|
||||
self._agentic = agentic
|
||||
self._avatar = avatar
|
||||
self._browser = browser
|
||||
self._fetch = FetchController(settings)
|
||||
self._docs = DocsController(settings)
|
||||
self._cost = CostController()
|
||||
self._chunks = ChunkController(settings)
|
||||
self._rsearch = RsearchController(settings)
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
action = self._actions.get(name)
|
||||
if action is None:
|
||||
return error_result(ToolInputError(f"Unknown tool: {name}"))
|
||||
|
||||
logger.info("Dispatch %s args=%s", name, list(arguments))
|
||||
try:
|
||||
if action.requires_auth and not self._client.authenticated:
|
||||
raise AuthRequiredError(
|
||||
"Not authenticated. Ask the user for credentials and call the login tool first.",
|
||||
tool=name,
|
||||
)
|
||||
resource_key = self._resource_key(action, arguments)
|
||||
if resource_key:
|
||||
cached = serve_resource(resource_key, self._settings.max_response_chars)
|
||||
if cached is not None:
|
||||
logger.info("Resource cache hit for %s (%s)", name, resource_key)
|
||||
return cached
|
||||
result = await self._run(action, arguments)
|
||||
if action.handler == "chunks":
|
||||
return result
|
||||
return wrap_if_large(result, self._settings.max_response_chars, resource_key)
|
||||
except DeviiError as exc:
|
||||
logger.info("Dispatch %s failed: %s", name, exc.message)
|
||||
return error_result(exc)
|
||||
except Exception as exc: # noqa: BLE001 - surfaced to the model as data
|
||||
logger.exception("Dispatch %s crashed", name)
|
||||
return unexpected_result(exc)
|
||||
|
||||
async def _run(self, action: Action, arguments: dict[str, Any]) -> str:
|
||||
if action.handler == "status":
|
||||
return json.dumps(
|
||||
{
|
||||
"authenticated": self._client.authenticated,
|
||||
"user": self._client.username,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
if action.handler == "login":
|
||||
result = await self._client.login(
|
||||
email=self._require(arguments, "email"),
|
||||
password=self._require(arguments, "password"),
|
||||
remember_me=str(arguments.get("remember_me", "on")).lower() not in ("", "false", "off", "no"),
|
||||
)
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
if action.handler == "logout":
|
||||
return json.dumps(await self._client.logout(), ensure_ascii=False)
|
||||
|
||||
if action.handler == "task":
|
||||
return await self._tasks.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "agentic":
|
||||
return await self._agentic.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "fetch":
|
||||
return await self._fetch.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "docs":
|
||||
return await self._docs.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "cost":
|
||||
return await self._cost.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "chunks":
|
||||
return await self._chunks.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "rsearch":
|
||||
return await self._rsearch.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "avatar":
|
||||
if self._avatar is None:
|
||||
return error_result(
|
||||
ToolInputError(
|
||||
"No avatar is attached; devii's on-screen actions need the web interface."
|
||||
)
|
||||
)
|
||||
return await self._avatar.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "client":
|
||||
if self._browser is None:
|
||||
return error_result(
|
||||
ToolInputError(
|
||||
"No browser is attached; client-side actions need the web interface."
|
||||
)
|
||||
)
|
||||
return await self._browser.dispatch(action.name, arguments)
|
||||
|
||||
return await self._run_http(action, arguments)
|
||||
|
||||
def _build_request(
|
||||
self, action: Action, arguments: dict[str, Any]
|
||||
) -> tuple[str, dict[str, Any], dict[str, Any], tuple[str, str] | None]:
|
||||
url_path = action.path
|
||||
params: dict[str, Any] = {}
|
||||
data: dict[str, Any] = {}
|
||||
file_field: tuple[str, str] | None = None
|
||||
|
||||
for param in action.params:
|
||||
if param.name not in arguments or arguments[param.name] is None:
|
||||
if param.required:
|
||||
raise ToolInputError(
|
||||
f"Missing required parameter '{param.name}' for {action.name}.")
|
||||
continue
|
||||
value = arguments[param.name]
|
||||
if param.location == "path":
|
||||
url_path = url_path.replace("{" + param.name + "}", quote(str(value), safe=""))
|
||||
elif param.location == "query":
|
||||
params[param.name] = value
|
||||
elif param.location == "body":
|
||||
data[param.name] = value
|
||||
elif param.location == "file":
|
||||
file_field = (param.name, str(value))
|
||||
|
||||
if action.freeform_body:
|
||||
extra = arguments.get("form_fields") or {}
|
||||
if isinstance(extra, dict):
|
||||
data.update({str(k): v for k, v in extra.items()})
|
||||
|
||||
return url_path, params, data, file_field
|
||||
|
||||
def _resource_key(self, action: Action, arguments: dict[str, Any]) -> str | None:
|
||||
try:
|
||||
if action.handler == "fetch" and action.name == "fetch_url":
|
||||
url = str(arguments.get("url", "")).strip()
|
||||
if not url:
|
||||
return None
|
||||
if "://" not in url:
|
||||
url = "https://" + url
|
||||
return f"fetch:{url}"
|
||||
if action.handler == "http" and action.method == "GET":
|
||||
url_path, params, _, _ = self._build_request(action, arguments)
|
||||
query = urlencode(sorted((str(k), str(v)) for k, v in params.items()))
|
||||
return f"http:GET {url_path}?{query}"
|
||||
except ToolInputError:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _run_http(self, action: Action, arguments: dict[str, Any]) -> str:
|
||||
url_path, params, data, file_field = self._build_request(action, arguments)
|
||||
|
||||
headers = {"X-Requested-With": "fetch"} if action.ajax else None
|
||||
response = await self._client.call(
|
||||
method=action.method,
|
||||
path=url_path,
|
||||
params=params or None,
|
||||
data=data or None,
|
||||
file_field=file_field,
|
||||
headers=headers,
|
||||
)
|
||||
if action.method in MUTATING_METHODS:
|
||||
record_mutation(action.name)
|
||||
store = get_store()
|
||||
if store is not None:
|
||||
store.invalidate_resources(prefix="http:")
|
||||
return format_response(response)
|
||||
|
||||
@staticmethod
|
||||
def _require(arguments: dict[str, Any], key: str) -> str:
|
||||
value = arguments.get(key)
|
||||
if value is None or str(value).strip() == "":
|
||||
raise ToolInputError(f"Missing required field '{key}'.")
|
||||
return str(value)
|
||||
@@ -0,0 +1,35 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
DOCS_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="search_docs",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Full-text search the DevPlace API documentation",
|
||||
description=(
|
||||
"Searches the platform's developer documentation and returns the most relevant "
|
||||
"sections (title and content). Use it to confirm how an endpoint, parameter, or "
|
||||
"feature works before acting."
|
||||
),
|
||||
handler="docs",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
Param(
|
||||
name="query",
|
||||
location="body",
|
||||
description="What to look for in the documentation.",
|
||||
required=True,
|
||||
),
|
||||
Param(
|
||||
name="max_results",
|
||||
location="body",
|
||||
description="Maximum number of documentation sections to return (1-10).",
|
||||
type="integer",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
FETCH_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="fetch_url",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Fetch a web page and return its readable text content",
|
||||
description=(
|
||||
"Retrieves any http(s) URL as a real browser would (stealth headers) and returns "
|
||||
"the page title and readable text with links preserved, safely truncated to fit "
|
||||
"the context window. Use it to read external pages, then summarize or create posts, "
|
||||
"gists, or articles from the content. Private and loopback addresses are refused."
|
||||
),
|
||||
handler="fetch",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
Param(
|
||||
name="url",
|
||||
location="body",
|
||||
description="The page URL to fetch (https is assumed if no scheme is given).",
|
||||
required=True,
|
||||
),
|
||||
Param(
|
||||
name="max_chars",
|
||||
location="body",
|
||||
description="Optional cap on returned characters; clamped to a context-safe maximum.",
|
||||
type="integer",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
REMOTE_NOTE = (
|
||||
"This is an EXTERNAL public service, not this DevPlace platform. Only call it when the user "
|
||||
"explicitly asks to search the web or an outside source; prefer platform tools otherwise."
|
||||
)
|
||||
|
||||
RSEARCH_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="rsearch",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Search the public web (and images) via the external rsearch aggregator",
|
||||
description=(
|
||||
"Queries multiple independent web search providers and returns ranked results "
|
||||
"(title, URL, description, source). Set content=true to also fetch each page's "
|
||||
"readable text, deep=true for a deeper research pass, and type=images for image "
|
||||
"results. " + REMOTE_NOTE
|
||||
),
|
||||
handler="rsearch",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
Param(name="query", location="body", description="The web search query.", required=True),
|
||||
Param(name="count", location="body", description="Number of results (1-100, default 10).", type="integer"),
|
||||
Param(name="content", location="body", description="Fetch full page content for each result.", type="boolean"),
|
||||
Param(name="deep", location="body", description="Run a deeper research pass.", type="boolean"),
|
||||
Param(name="type", location="body", description="Result type: 'web' (default) or 'images'."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="rsearch_answer",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Get an AI answer to a question, grounded in fresh public web search",
|
||||
description=(
|
||||
"Sends the question to an external AI that autonomously searches the public web and "
|
||||
"returns a written answer plus the source links it used. Use for current, real-world "
|
||||
"questions the platform cannot answer. " + REMOTE_NOTE
|
||||
),
|
||||
handler="rsearch",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
Param(name="query", location="body", description="The question or prompt to answer.", required=True),
|
||||
Param(name="content", location="body", description="Let the AI read full page content while answering.", type="boolean"),
|
||||
Param(name="count", location="body", description="Max sources to consider (1-100, default 10).", type="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="rsearch_chat",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Send a prompt to the external rsearch AI for a direct answer (no web search)",
|
||||
description=(
|
||||
"Direct chat completion from the external rsearch AI model, with no web search or "
|
||||
"platform context. Set json=true to force a JSON-only answer, or pass a system message "
|
||||
"to steer the persona. " + REMOTE_NOTE
|
||||
),
|
||||
handler="rsearch",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
Param(name="prompt", location="body", description="The prompt to send.", required=True),
|
||||
Param(name="json", location="body", description="Force a valid-JSON-only response.", type="boolean"),
|
||||
Param(name="system", location="body", description="Optional system message to steer the answer."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="rsearch_describe_image",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Describe a public image URL using the external rsearch vision model",
|
||||
description=(
|
||||
"Sends an image URL to the external rsearch vision model and returns a written "
|
||||
"description. " + REMOTE_NOTE
|
||||
),
|
||||
handler="rsearch",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
Param(name="url", location="body", description="Public URL of the image to describe.", required=True),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
ParamLocation = Literal["path", "query", "body", "file"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Param:
|
||||
name: str
|
||||
location: ParamLocation
|
||||
description: str
|
||||
required: bool = False
|
||||
type: str = "string"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
name: str
|
||||
method: str
|
||||
path: str
|
||||
summary: str
|
||||
description: str = ""
|
||||
params: tuple[Param, ...] = ()
|
||||
requires_auth: bool = True
|
||||
handler: Literal[
|
||||
"http", "login", "logout", "status", "task", "agentic", "avatar", "client", "fetch",
|
||||
"docs", "cost", "chunks", "rsearch"
|
||||
] = "http"
|
||||
freeform_body: bool = False
|
||||
ajax: bool = False
|
||||
|
||||
def tool_schema(self) -> dict[str, Any]:
|
||||
properties: dict[str, Any] = {}
|
||||
required: list[str] = []
|
||||
for param in self.params:
|
||||
if param.type == "array":
|
||||
properties[param.name] = {
|
||||
"type": "array",
|
||||
"items": {"type": "object"},
|
||||
"description": param.description,
|
||||
}
|
||||
else:
|
||||
properties[param.name] = {
|
||||
"type": param.type,
|
||||
"description": param.description,
|
||||
}
|
||||
if param.required:
|
||||
required.append(param.name)
|
||||
if self.freeform_body:
|
||||
properties["form_fields"] = {
|
||||
"type": "object",
|
||||
"description": "Additional form fields as key/value string pairs.",
|
||||
"additionalProperties": {"type": "string"},
|
||||
}
|
||||
text = self.summary if not self.description else f"{self.summary}. {self.description}"
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": text,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Catalog:
|
||||
actions: tuple[Action, ...] = field(default_factory=tuple)
|
||||
|
||||
def by_name(self) -> dict[str, Action]:
|
||||
return {action.name: action for action in self.actions}
|
||||
|
||||
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]]:
|
||||
return [
|
||||
action.tool_schema()
|
||||
for action in self.actions
|
||||
if authenticated or not action.requires_auth
|
||||
]
|
||||
Reference in New Issue
Block a user