generated from exteraGramPlugins/pluginBase
Replace the sample plugin with an AI chat summarizer that renders its
output as a native RichMessage injected into the chat.
Summarization pipeline:
- Fetch chat/topic history via paginated TL requests.
- Send a system prompt, the user's instructions and an anonymized JSON
transcript as three separate roles. Only message text and attachment
names leave the device; senders and message ids are replaced by
indices, and out-of-range reply parents are included with negative ids
so replies keep their context.
- Stream the response over SSE, reading reasoning from reasoning_content,
reasoning or inline <think> tags.
- Substitute the model's {userN}/{msgN} placeholders back into real names
and deep links. Placeholders are used instead of Markdown links because
a half-streamed link renders as broken literal text.
Rendering:
- Compile Markdown into TL_iv page blocks (headings, lists, quotes, code,
dividers, inline styles) since the app's own converter works off
Android Spanned spans rather than Markdown source.
- Show progress stages immediately on click, animate reasoning while it
streams, then collapse it into a tappable details block.
- Attribute the card to a client-side mock sender and insert it directly
after the newest analyzed message, scrolling it into view.
Persistence:
- Store summaries in SQLite keyed by (dialog, topic, anchor) and
re-inject them when the chat is reopened.
Also add tools/release.py and a Gitea workflow that builds, verifies and
publishes dist/<id>.plugin to the Releases tab, and extend
tools/verify_bundle.py to 104 offline checks covering the transcript,
streaming, rendering, injection and storage behaviour.
170 lines
6.1 KiB
Python
Executable File
170 lines
6.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Publishes a built .plugin file to a Gitea release.
|
|
|
|
Creates the release for the given tag if it does not exist, replaces any
|
|
same-named asset already attached, then uploads the artifact. Re-running is
|
|
safe: the release is reused and the asset is replaced rather than duplicated.
|
|
|
|
Stdlib only, so it runs on any runner image without a pip install step.
|
|
|
|
Usage:
|
|
tools/release.py --api <repo-api-url> --tag v1.0.0 --artifact dist/x.plugin
|
|
|
|
The token is read from GITEA_TOKEN (or GITHUB_TOKEN as a fallback).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def fail(message: str) -> "None":
|
|
print(f"error: {message}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
def request(
|
|
method: str,
|
|
url: str,
|
|
token: str,
|
|
body: Optional[bytes] = None,
|
|
content_type: Optional[str] = None,
|
|
) -> Tuple[int, bytes]:
|
|
"""Performs one API call, returning (status, body) instead of raising."""
|
|
req = urllib.request.Request(url, data=body, method=method)
|
|
req.add_header("Authorization", f"token {token}")
|
|
req.add_header("Accept", "application/json")
|
|
if content_type:
|
|
req.add_header("Content-Type", content_type)
|
|
try:
|
|
with urllib.request.urlopen(req) as response:
|
|
return response.status, response.read()
|
|
except urllib.error.HTTPError as exc:
|
|
return exc.code, exc.read()
|
|
except urllib.error.URLError as exc:
|
|
fail(f"{method} {url} failed: {exc.reason}")
|
|
raise # unreachable, keeps type checkers quiet
|
|
|
|
|
|
def find_release(api: str, tag: str, token: str) -> Optional[dict]:
|
|
status, payload = request("GET", f"{api}/releases/tags/{tag}", token)
|
|
if status == 404:
|
|
return None
|
|
if status != 200:
|
|
fail(f"looking up release {tag} returned HTTP {status}: {payload[:400]!r}")
|
|
return json.loads(payload)
|
|
|
|
|
|
def create_release(api: str, tag: str, token: str, commitish: str, notes: str) -> dict:
|
|
body = json.dumps(
|
|
{
|
|
"tag_name": tag,
|
|
"target_commitish": commitish,
|
|
"name": tag,
|
|
"body": notes,
|
|
"draft": False,
|
|
"prerelease": False,
|
|
}
|
|
).encode()
|
|
status, payload = request("POST", f"{api}/releases", token, body, "application/json")
|
|
if status != 201:
|
|
fail(f"creating release {tag} returned HTTP {status}: {payload[:400]!r}")
|
|
return json.loads(payload)
|
|
|
|
|
|
def delete_existing_assets(api: str, release_id: int, name: str, token: str) -> int:
|
|
"""Removes same-named assets, since Gitea keeps duplicates side by side."""
|
|
status, payload = request("GET", f"{api}/releases/{release_id}/assets", token)
|
|
if status != 200:
|
|
fail(f"listing assets returned HTTP {status}: {payload[:400]!r}")
|
|
removed = 0
|
|
for asset in json.loads(payload) or []:
|
|
if asset.get("name") != name:
|
|
continue
|
|
status, payload = request(
|
|
"DELETE", f"{api}/releases/{release_id}/assets/{asset['id']}", token
|
|
)
|
|
if status not in (204, 404):
|
|
fail(f"deleting asset {asset['id']} returned HTTP {status}: {payload[:400]!r}")
|
|
removed += 1
|
|
return removed
|
|
|
|
|
|
def upload_asset(api: str, release_id: int, path: Path, name: str, token: str) -> dict:
|
|
"""Uploads the artifact as multipart/form-data."""
|
|
boundary = uuid.uuid4().hex
|
|
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
payload = b"".join(
|
|
[
|
|
f"--{boundary}\r\n".encode(),
|
|
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'.encode(),
|
|
f"Content-Type: {mime}\r\n\r\n".encode(),
|
|
path.read_bytes(),
|
|
f"\r\n--{boundary}--\r\n".encode(),
|
|
]
|
|
)
|
|
url = f"{api}/releases/{release_id}/assets?name={urllib.parse.quote(name)}"
|
|
status, response = request(
|
|
"POST", url, token, payload, f"multipart/form-data; boundary={boundary}"
|
|
)
|
|
if status != 201:
|
|
fail(f"uploading {name} returned HTTP {status}: {response[:400]!r}")
|
|
return json.loads(response)
|
|
|
|
|
|
def main(argv: Optional[list] = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--api", required=True, help="repo API base, e.g. https://host/api/v1/repos/owner/repo")
|
|
parser.add_argument("--tag", required=True, help="release tag")
|
|
parser.add_argument("--artifact", required=True, help="file to attach")
|
|
parser.add_argument("--commitish", default="", help="target commit for a new tag")
|
|
parser.add_argument("--asset-name", default="", help="attachment name; defaults to the file name")
|
|
args = parser.parse_args(argv)
|
|
|
|
token = os.environ.get("GITEA_TOKEN") or os.environ.get("GITHUB_TOKEN") or ""
|
|
if not token:
|
|
fail("GITEA_TOKEN is not set")
|
|
|
|
artifact = Path(args.artifact)
|
|
if not artifact.is_file() or artifact.stat().st_size == 0:
|
|
fail(f"artifact {artifact} is missing or empty")
|
|
asset_name = args.asset_name or artifact.name
|
|
|
|
api = args.api.rstrip("/")
|
|
version = json.loads((REPO_ROOT / "plugin.json").read_text(encoding="utf-8"))["version"]
|
|
|
|
release = find_release(api, args.tag, token)
|
|
if release is None:
|
|
notes = (
|
|
f"Automated build of version {version}.\n\n"
|
|
"Install by opening the attached `.plugin` file in exteraGram."
|
|
)
|
|
release = create_release(api, args.tag, token, args.commitish, notes)
|
|
print(f"created release {args.tag}")
|
|
else:
|
|
print(f"reusing existing release {args.tag}")
|
|
|
|
release_id = int(release["id"])
|
|
removed = delete_existing_assets(api, release_id, asset_name, token)
|
|
if removed:
|
|
print(f"removed {removed} previous copy/copies of {asset_name}")
|
|
|
|
asset = upload_asset(api, release_id, artifact, asset_name, token)
|
|
print(f"uploaded {asset_name} ({artifact.stat().st_size} bytes) -> {asset.get('browser_download_url', '')}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|