Plugins entwickeln – das Handbuch
Auf dieser Seite: wie du selbst ein Plugin baust – zehn Kapitel, jedes vom leeren
register(ctx) bis zum lauffähigen Code. Zum Anfangen: die öffentliche
Vorlage klonen.
Du willst Plugins nur nutzen – installieren, aktualisieren, verwalten – oder etwas nachschlagen? Das steht in
Plugins · Überblick.
Schritt-für-Schritt-Anleitungen für das, was Plugins am häufigsten tun. Jedes Kapitel beginnt bei einem leeren register(ctx) und endet mit lauffähigem Code; die vollständige Referenz jedes Aufrufs steht in der Vorlage unter docs/context.md. Die Kapitel 3, 4 und 9 brauchen Theron 0.16.0 oder neuer, Kapitel 10 0.17.0 (min_theron in der plugin.toml).
Zwei Regeln gelten überall:
- Oberfläche nur in der App. Seiten, Panels, Fenster, Buttons und Dialoge gibt es nur in der Desktop-App. Pack sie in
if ctx.ui.available:und importiere PySide6 erst darin – ein Slave und das Headless-Docker-Image haben kein PySide6. - Der UI-Thread. Widgets, Button-Callbacks und Dialoge laufen im UI-Thread. Blockiere ihn nie: Langsames startest du mit
ctx.spawn(fn, …), Ergebnisse gibst du mitctx.ui.call(fn, …)zurück.
1. Eine Seite hinzufügen
Eine Seite ist ein Eintrag in Therons Navigation mit einem eigenen PySide6-Widget.
# my_plugin/__init__.py
def register(ctx):
if ctx.ui.available:
from .pages import BoardPage
ctx.ui.add_page(ctx.tr("Board"), BoardPage, icon="▦", id="board")
# my_plugin/pages.py
from PySide6.QtWidgets import QLabel, QListWidget, QPushButton, QVBoxLayout, QWidget
class BoardPage(QWidget):
def __init__(self, ctx): # die Factory bekommt ctx
super().__init__()
self.ctx = ctx
lay = QVBoxLayout(self)
lay.setContentsMargins(28, 28, 28, 28)
title = QLabel(ctx.tr("Board").upper())
title.setObjectName("h1") # Therons Stile: h1, h2, dim, mono; Buttons primary, ghost, danger
self.items = QListWidget()
load = QPushButton(ctx.tr("Load"))
load.setObjectName("primary")
load.clicked.connect(self.load)
for w in (title, self.items, load):
lay.addWidget(w)
def load(self):
future = self.ctx.spawn(fetch_cards, self.ctx) # im Hintergrund
future.add_done_callback(lambda f: self.ctx.ui.call(self.show, f.result() or []))
def show(self, cards): # zurück im UI-Thread
self.items.clear()
self.items.addItems([c["name"] for c in cards])
titleist der Navigationseintrag (in Großbuchstaben nach Therons Seiten),iconein Zeichen davor.idbenennt die Seite für Panels und Buttons anderer Plugins:"<Plugin-ID>/<id>", hier"my-plugin/board".- Die Factory läuft einmal, wenn das Fenster gebaut wird. Wirft sie einen Fehler, zeigt die Seite ihn an; der Traceback steht im Protokoll des Plugins (Einstellungen → Plugins → Protokoll).
2. Einen Prozess-Node hinzufügen
Ein Node erscheint in der Palette des Prozess-Editors wie Therons eigene und läuft, wenn ein Prozess ihn erreicht.
def register(ctx):
async def check(node):
text = "\n\n".join(h["text"] for h in node.handovers) # was die vorigen Schritte übergeben haben
node.progress(ctx.tr("Checking …")) # im Cockpit sichtbar
verdict = await ctx.ai.ask(f"Does this contain a price? Answer YES or NO.\n\n{text}", model="haiku")
if verdict.strip().upper().startswith("YES"):
return node.success(text) # → Ausgang ✔, der Text geht weiter
return node.failure(ctx.tr("No price found.")) # → Ausgang ✖
ctx.nodes.register("check-price", label=ctx.tr("Check price"), icon="€", run=check,
description=ctx.tr("Goes on via ✔ if the input contains a price."),
ai_hint="Checks a text for a price – use after research steps")
Felder. fields=[{"key": "rule", "type": "text", "label": "Rule", "default": "…"}] gibt dem Node Einstellungen (Typen text, textarea, number, bool, choice, tasklist, project); gelesen werden sie als node.config["rule"].
Mehr Ausgänge und Eingänge – ein Switch, der verteilt, ein Merge, der wartet:
def cases(config): # Ausgänge aus den Einstellungen des Nodes
return [{"id": c.strip().lower(), "label": c.strip()} for c in config["cases"].split(",")] + ["other"]
async def route(node):
text = " ".join(h["text"] for h in node.handovers).lower()
hit = next((o["id"] for o in node.outputs if o["id"] in text), "other")
return node.route(hit, f"matched {hit}") # eine ID oder eine Liste von IDs
ctx.nodes.register("switch", label="Keyword switch", run=route, outputs=cases,
fields=[{"key": "cases", "type": "text", "default": "Bug, Feature"}])
ctx.nodes.register("merge", label="Merge", outputs="success", wait="all",
inputs=[{"id": "code", "label": "Code"}, {"id": "docs", "label": "Docs"}],
run=lambda node: node.success(node.inputs["code"][0]["text"] + "\n\n" + node.inputs["docs"][0]["text"]))
outputs: "success_failure" (✔/✖, Standard), "success" (nur ✔), eine Liste von IDs / {"id", "label"} oder eine Funktion der Einstellungen. inputs: keine (ein Eingang) oder eine Liste – wait="all" wartet auf jeden verbundenen Eingang, "any" läuft beim ersten. Alles zu Nodes: docs/nodes.md der Vorlage.
3. Fenster auf Therons Seiten
Panels – ein Fenster neben oder unter einer Seite
def register(ctx):
if ctx.ui.available:
from .panels import RunInfo
ctx.ui.add_panel("cockpit", ctx.tr("Run info"), RunInfo, id="run-info", icon="ⓘ")
ctx.ui.add_panel(["processes", "agents"], ctx.tr("Hints"), Hints, area="bottom", collapsed=True)
class RunInfo(QWidget):
def __init__(self, view): # die Factory eines Panels bekommt den view
super().__init__()
self.view, self.ctx = view, view.ctx
self.label = QLabel()
QVBoxLayout(self).addWidget(self.label)
view.on_context(self.show) # die Seite zeigt etwas anderes
self.show(view.context)
def show(self, context):
run = self.ctx.runs.get(context["run_id"]) if context.get("run_id") else None
self.label.setText(run["title"] if run else self.ctx.tr("No task open."))
- Seiten:
cockpit,theron,projects,processes,agents,tasks,databases, die Seite eines Plugins ("my-plugin/board"), eine Liste davon oder"*"für jede Seite. - **
area:**"right"(eine Spalte neben der Seite) oder"bottom"(eine Zeile darunter). Mehrere Panels stapeln sich, sortiert nachorder; der Nutzer zieht die Trenner und klappt Panels ein (▾/▸) – Theron merkt sich beides. - **
view.context** – was die Seite zeigt:
| Seite | view.context |
|---|---|
cockpit, theron | {"page", "project_id", "run_id"} |
projects | {"page", "project_id"} |
processes | {"page", "process_id"} |
agents | {"page", "agent_id"} |
tasks | {"page", "project_id", "tasklist_id"} |
databases | {"page", "database"} |
| Seite eines Plugins | {"page"} |
view.on_context(fn)–fn(context), sobald sich das ändert.view.set_title(text),view.set_visible(bool).
Eigene Fenster
ctx.ui.open_window(ctx.tr("Live log"), lambda view: LiveLog(view, run_id), id=f"log-{run_id}", width=760, height=520)
ctx.ui.close_window(f"log-{run_id}")
Ein Plugin-Fenster ist nicht modal und bleibt neben Theron offen. Dieselbe id noch einmal holt es nach vorn. open_window geht aus jedem Thread (ein Event, ein Zeitplan); view.context ist, was die vordere Seite zeigt.
4. Buttons und Formulare erweitern
Buttons auf Seiten – ctx.ui.add_action
def register(ctx):
if not ctx.ui.available:
return
def export(context): # im UI-Thread
tl = ctx.tasklists.get(context["tasklist_id"])
...
ctx.ui.add_action("tasks", lambda c: ctx.tr("Export") if c.get("tasklist_id") else None, export,
id="export", icon="⇩", style="primary")
place | Wo | Seiten |
|---|---|---|
"top" (Standard) | die Werkzeugleiste der Seite – oder eine Leiste über der Seite | jede Seite |
"chat" | neben „Senden“ | cockpit, theron |
"new" | neben „+ Neu …“ | agents, processes, projects |
"lists" | unter den Task-Listen | tasks |
"items" | neben „+ Epic / + Requirement“ | tasks |
"sidebar" | Therons Seitenleiste, ein Button für alle Seiten | "*" |
labelist Text oderlabel(context)→ Text, oderNone, um den Button auszublenden. Theron fragt erneut, wenn die Seite etwas anderes zeigt und nach einem Klick;ctx.ui.refresh_actions()fragt sofort (z. B. wenn Hintergrundarbeit fertig ist).run(context)bekommt denselben Kontext wie Panels; im Chat zusätzlichcontext["input"]– den bisher getippten Text.style:ghost,primaryoderdanger.
Kleine Dialoge (UI-Thread): ctx.ui.ask_text(title, text, default="", suggestions=None) → Text oder None (mit suggestions eine Liste zum Auswählen, Tippen bleibt möglich – ab 0.19.1), ctx.ui.ask_choice(title, text, options) → die gewählte Option oder None, ctx.ui.message(title, text, level). Die Chat-Eingabe: ctx.ui.chat_input(page="cockpit"), ctx.ui.set_chat_input(text, page="cockpit", append=False).
Ein vollständiger Ablauf – fragen, im Hintergrund arbeiten, wieder fragen, fertig:
def clicked(context):
tid = context["tasklist_id"]
board = ctx.ui.ask_text("Sync with Trello", "Board id or link:")
if not board:
return
future = ctx.spawn(inspect_board, tid, board) # Netzwerk: nicht im UI-Thread
future.add_done_callback(lambda f: ctx.ui.call(inspected, tid, f.result()))
def inspected(tid, found): # wieder im UI-Thread
choice = ctx.ui.ask_choice("Sync with Trello", found["text"], ["Read", "Write"])
if choice:
ctx.storage.set(f"link:{tid}", {"board": found["id"], "direction": choice.lower()})
ctx.ui.refresh_actions() # der Button wird zu „⟳ Sync“
Das Plugin Trello Sync aus dem Plugin-Katalog ist genau dieser Ablauf, vollständig.
Felder in Therons Formularen – ctx.fields
ctx.fields.add("agent", "jira_user", label=ctx.tr("Jira user"), description="…")
ctx.fields.add("project", "customer", label="Customer", type="choice", options=["internal", "A", "B"])
ctx.fields.add("process", "cost_center", label="Cost center", type="number", default=0)
Formulare: agent, process, project. Typen: text, textarea, number, bool, choice. Die Felder stehen in einem eigenen Abschnitt des Plugins im Formular und werden mit ihm gespeichert. Lesen und schreiben geht überall (auch auf einem Slave):
user = ctx.fields.get("agent", agent_id, "jira_user")
ctx.fields.all("project", project_id) # {key: value} mit Standardwerten
ctx.fields.set("agent", agent_id, "jira_id", 4711) # auch Schlüssel ohne Formularfeld
Buttons in den Formularen – ctx.ui.add_form_button
def check(form):
if not form.get("jira_user"):
form.set("jira_user", "bot." + form.values()["name"].lower()) # im Formular, gespeichert mit „Speichern“
ctx.ui.add_form_button("agent", lambda form: "Check Jira" if form.id else None, check, icon="✓")
form hat kind, id (None bei einem neuen, ungespeicherten), data (wie gespeichert), values() (wie bearbeitet, mit plugin_fields), get(key) / set(key, value) für die eigenen Felder des Plugins, save() und reload().
5. Mit Task-Listen arbeiten
Therons Task-Listen enthalten Epics, Epics enthalten Requirements. Berechtigung: tasklists.
projects = ctx.tasklists.projects() # [{"id", "name", "workspace"}]
tl_id = ctx.tasklists.create(projects[0]["id"], "Sprint 12", description="…")
epic = ctx.tasklists.add_epic(tl_id, "Login")
req = ctx.tasklists.add_requirement(epic, "Login form", description="…", acceptance="works with SSO",
status="open", depends_on=[])
ctx.tasklists.update_requirement(req, status="done", result="merged")
tl = ctx.tasklists.get(tl_id) # die Liste mit "epics" → "requirements"
for e in tl["epics"]:
for r in e["requirements"]:
print(e["name"], r["title"], r["status"])
| Aufruf | Macht |
|---|---|
projects(), list(project_id=None), get(id), find(project_id, name) | lesen |
create, update(id, name=, description=, process_id=), delete | Task-Listen |
add_epic, update_epic(id, name=, description=, depends_on=, closed=, status=), delete_epic, epic(id) | Epics |
add_requirement, update_requirement(id, title=, description=, acceptance=, tech_rules=, status=, result=, depends_on=), delete_requirement, requirement(id) | Requirements |
Status: open, in_progress, hitl, done, failed, skipped, closed. Die App zeigt Änderungen sofort. Soll Theron eine Liste abarbeiten, startest du sie als Task: ctx.runs.start(project_id, "Implement the list", …) mit einem Prozess, der einen Task-Listen-Node hat – oder der Nutzer klickt „▶ Task-Liste“.
6. Auf Events reagieren
Events sind Benachrichtigungen: Theron sagt dem Plugin, was passiert ist, und wartet nicht. Sie kommen in der App und auf einem Slave an (ab app.started), nicht in Konsolen-Läufen.
@ctx.events.on("run.finished")
def finished(event):
if event["goal_reached"]:
ctx.notify(ctx.tr("“{title}” is done.", title=event["title"]))
@ctx.events.on("hitl.asked")
async def asked(event):
await send_to_phone(event["question"], event["options"])
ctx.events.on("*", lambda e: ctx.log(e["name"])) # jedes Event
Die wichtigsten (alle mit Inhalt in docs/events.md der Vorlage):
| Event | Wann |
|---|---|
app.started, app.stopping, settings.changed | Theron startet / beendet sich, die Einstellungen des Plugins haben sich geändert |
run.status, run.finished, run.event | ein Task ändert seinen Status, endet oder schreibt etwas in sein Protokoll |
agent.started, agent.finished, agent.status | ein Agent beginnt / beendet eine Runde, meldet seinen Zwischenstand |
node.transition, run.reset | der Prozess geht von Node zu Node, wird ab einem Node neu gestartet |
hitl.asked, hitl.answered | Theron fragt den Nutzer, die Frage wurde beantwortet |
tasklist.created | ein Task-Listen-Node hat eine Liste angelegt oder gefüllt |
<kind>.saved, <kind>.deleted | Daten haben sich geändert – siehe Kapitel 8 |
Callbacks dürfen async def oder normale Funktionen sein (die laufen in einem Worker-Thread). Jedes Event kommt als ein dict mit "name"; Fehler landen im Protokoll und stören Theron nicht.
7. Eigene Events werfen
Die eigenen Events eines Plugins beginnen mit seiner ID. Andere Plugins (und das Plugin selbst, mit include_own=True) abonnieren sie wie Therons.
# Plugin "whatsapp"
ctx.events.emit("whatsapp.sent", {"to": number, "text": text})
# jedes andere Plugin
@ctx.events.on("whatsapp.sent")
def sent(event):
ctx.log(f"{event['source']} sent a WhatsApp to {event['to']}") # source: "plugin:whatsapp"
Sollen andere vorher etwas ändern können, biete stattdessen einen Hook an – das Plugin wartet auf ihre Antwort:
msg = await ctx.hooks.call("whatsapp.before_send", {"to": number, "text": text})
await send(msg["to"], msg["text"])
# ein anderes Plugin
@ctx.hooks.on("whatsapp.before_send")
def sign(hook):
hook.data["text"] += "\n– sent by Theron"
Namen: <Plugin-ID>.<Name> mit Buchstaben, Ziffern, _, -, .; Therons Präfixe (run., agent. …) sind reserviert.
8. Auf Transaktionen reagieren
Jede Änderung an Therons Daten ist eine Transaktion mit einem Event, nachdem sie gespeichert ist – egal wer sie gemacht hat: der Nutzer, die Engine (z. B. der Status eines Requirements, während eine Task-Liste läuft), die lokale API, der Server-Sync oder ein anderes Plugin:
| Event | Inhalt |
|---|---|
project.saved, process.saved, agent.saved, tasklist.saved, epic.saved, requirement.saved | {"name", "kind", "id", "data", "source"} – data ist der gespeicherte Datensatz |
….deleted (dieselben Arten) | {"name", "kind", "id", "data": None, "source"} |
@ctx.events.on("requirement.saved")
async def changed(event):
req = event["data"]
if req["status"] == "done":
await close_ticket(req["title"])
@ctx.events.on("agent.deleted")
def gone(event):
ctx.storage.delete(f"jira:{event['id']}")
- Eigene Änderungen kommen nicht zurück: Was das Plugin über
ctx.tasklists,ctx.processes,ctx.agentsundctx.fieldsändert, trägt"source": "plugin:<id>"und wird demselben Plugin nicht zugestellt – keine endlosen Sync-Schleifen. Mitinclude_own=Truekommen sie trotzdem. Andere Plugins bekommen sie mit dieser Quelle. sourcesagt, wer geändert hat:"plugin:<id>"für ein Plugin,"app"für alles andere (Nutzer, Engine, lokale API, Server-Sync).
Vor einer Transaktion – um etwas zu prüfen oder zu ändern, bevor Theron es tut, nimm einen Hook: agent.before_start (Prompt und Regeln eines Agents), agent.after_end (sein Ergebnis und Urteil), hitl.question (eine Rückfrage – oder sie selbst beantworten), message.user, process.design.before/after, agent.design.before/after, tasklist.before_create, assistant.process.before_create. Die Handler laufen in der Plugin-Reihenfolge, die der Nutzer festgelegt hat, und dürfen hook.data ändern.
9. Laufende Agents und ihre Logs lesen
ctx.runs liest, woran Theron gerade arbeitet – in der App und auf einem Slave (dort laufen die Tasks). Berechtigung: runs.
for run in ctx.runs.active(): # Tasks, die laufen oder auf eine Antwort warten
state = ctx.runs.state(run["id"])
print(run["title"], state["status"], state["question"])
for step in state["steps"]:
print(" ", step["agent"], step["status"], step["status_note"])
for w in ctx.runs.workers(): # die Agents, die gerade arbeiten
minutes = (time.time() - w["started"]) / 60
print(w["agent"], w["step_id"], f"{minutes:.0f} min", w["status_note"])
| Aufruf | Liefert |
|---|---|
active(project_id=None) | die Tasks mit Status running / waiting_input |
state(run_id) | Status, Phase, Prozess, question (die offene Rückfrage), agent_calls, cost_usd, steps (Status, Aktivität, status_note – der letzte Zwischenstand des Agents, Aufrufe, Fehlschläge, Schleife), workers |
workers(run_id=None) | arbeitende Agents: key, run_id, step_id, step, agent, engine, started, loop/item, status_note, activity |
events(run_id, since=0, types=None, limit=200) | das Protokoll als Events nach Event-ID since – merk dir die letzte id, um nur Neues zu lesen |
log(run_id, last=100, agent=None) | die letzten Zeilen als Text: 12:03:10 Developer ⚙ Bash: pytest |
watch(fn, run_id=None, types=None, agent=None) | fn(event) für jedes neue Protokoll-Event (mit "line"); .cancel() beendet es |
await ask_worker(run_id, key, question) | fragt einen arbeitenden Agent nebenbei – er arbeitet weiter; liefert seine kurze Antwort |
Die Event-Typen des Protokolls: agent_start, agent_text, agent_tool (summary), agent_log, agent_status, agent_end (result, verdict, error), orchestrator, user, input_request (question, options), input_resolved, handover, routing, decision, error, alert, status, complete – jeweils mit step und agent, wo es passt.
Darauf reagieren:
async def on_output(event):
if "Traceback" in (event.get("text") or event.get("summary") or ""):
worker = next((w for w in ctx.runs.workers(event["run_id"]) if w["agent"] == event["agent"]), None)
if worker:
hint = await ctx.runs.ask_worker(event["run_id"], worker["key"], "What fails and why?")
ctx.notify(f"{event['agent']}: {hint}", "warning")
ctx.on_start(lambda: ctx.runs.watch(on_output, types=["agent_tool", "agent_text"]))
| Aufruf | Macht |
|---|---|
message(run_id, text) | sagt THERON etwas, wie eine Chat-Nachricht |
answer(run_id, text) | beantwortet die offene Rückfrage (state(run_id)["question"]) |
stop(run_id, soon=False) | stoppt den Task – soon: lässt die arbeitenden Agents fertig werden |
reset(run_id, step_id) | startet den Prozess ab einem Node neu (wie ↺) |
start(project_id, text, …) | startet einen neuen Task |
ask_worker nutzt das Claude-Abo des Nutzers (eine Kopie der Session des Agents antwortet einmal, wie die Zwischenstände alle paar Minuten); Agents anderer Engines antworten mit einer Zusammenfassung ihrer letzten Schritte.
monitor.py und ui_monitor.py der Vorlage sind ein vollständiges Beispiel: eine Überwachung, die bei Fehlern benachrichtigt, eine Prüfung auf Agents, die zu lange arbeiten, ein Panel neben dem Cockpit und ein Live-Log-Fenster.
10. Den Chat erweitern
Therons Chat bietet nur die Haken – was darin erscheint, entscheidet das Plugin: ein Player für Audio, eine Dokument-Vorschau, ein Diagramm, Buttons. Vier Bausteine, alle in ctx.chat (my_plugin/chat.py nutzt jeden davon):
| Du willst … | Nimm |
|---|---|
| Text in jeder Nachricht anders darstellen (Farbcode, Dateipfad, Ticketnummer) | ctx.chat.add_format(pattern, render) |
| dass ein Node etwas Eigenes postet (Liste mit Buttons, Diagramm, Player) | node.say(text, kind=..., data=...) + ctx.chat.add_renderer(kind, render) |
| etwas Klickbares in deinem Rich Text | ctx.chat.link(name, text, **params) + ctx.chat.on_link(name, fn) |
| dass der Chat einen neuen Zustand zeigt (▶ → ■, neue Zahl) | ctx.chat.refresh() |
| dass ein Node den Nutzer fragt und wartet | await node.ask(question, options) |
Formatierung. render(match) gibt Qt-Rich-Text für den gefundenen Text zurück – oder None, dann bleibt er, wie er ist. Es läuft für jede Nachricht, sobald ein Chat gezeichnet wird: halte es schnell und escape, was aus dem Treffer kommt:
import html, re
from pathlib import Path
AUDIO = re.compile(r"(?<![\w/])(/[^\s<>\"']+?\.(?:mp3|wav))\b")
def audio_path(m):
path = m.group(1)
if not Path(path).is_file():
return None # nicht da: normaler Text
return html.escape(path) + " " + ctx.chat.link("play", "▶", src=path)
ctx.chat.add_format(AUDIO, audio_path)
Blöcke. Ein Node postet node.say(text, kind="chart", data={...}); Theron speichert das Event mit dem Lauf und fragt deinen Renderer jedes Mal, wenn der Chat gezeichnet wird (auch nach einem Neustart). text ist der Ersatz, wo der Block nicht gezeichnet werden kann – im Web, in Telegram oder wenn das Plugin aus ist. Ohne kind ist node.say("**Markdown**") eine normale Nachricht.
ctx.chat.add_renderer("chart", lambda data: f'<p><b>{html.escape(data["title"])}</b></p>'
f'<img src="{html.escape(data["png"])}" width="440">')
async def plot(node):
png = await render_chart(node.input, ctx.data_dir / f"chart-{node.run_id}.png")
node.say("Ein Diagramm der Zahlen: " + str(png), kind="chart", data={"title": "Umsatz", "png": str(png)})
return node.success(str(png))
Buttons und Klicks. ctx.chat.link(...) erzeugt ein <a href="theron-plugin://<plugin>/<name>?…">; ein Klick ruft deinen on_link-Handler mit den Parametern auf (im Hintergrund, nicht im UI-Thread). Ändere deinen Zustand, dann ctx.chat.refresh() – Renderer und Formate werden neu gefragt:
def play(params):
player.toggle(params["src"]) # dein eigener Player – z. B. afplay / winsound / ffplay per subprocess
ctx.chat.refresh() # aus ▶ wird ■
ctx.chat.on_link("play", play)
Im Chat fragen. await node.ask(question, options=None, recommended=None, details="") zeigt die Frage wie Therons eigene (✋ am Lauf, Antwort-Buttons, Web und Telegram funktionieren auch) und gibt die Antwort zurück. Prüf sie und frag erneut, wenn sie nicht passt:
async def pick(node):
voices = await load_voices()
node.say("\n".join(f"{i}. {v}" for i, v in enumerate(voices, 1)), kind="voices", data={"voices": voices})
for _ in range(3):
answer = await node.ask("Welche Stimme? (Nummer oder Name)")
if answer.isdigit() and 1 <= int(answer) <= len(voices):
return node.success(voices[int(answer) - 1])
return node.failure("Keine Stimme gewählt.")
Node-Felder, die dem Node folgen. options darf eine Funktion der Node-Einstellungen sein, und show_if blendet ein Feld erst ein, wenn ein anderes einen bestimmten Wert hat – z. B. eine Stimmenliste nur im Modus „Fest“:
fields=[{"key": "mode", "type": "choice", "default": "manual",
"options": [{"value": "manual", "label": "Manual"}, {"value": "fixed", "label": "Fixed"}]},
{"key": "voice", "type": "choice", "show_if": {"mode": "fixed"},
"options": lambda config: [{"value": v["id"], "label": v["name"]} for v in cached_voices()]}]
Die Funktion läuft im UI-Thread – gib zurück, was du zwischengespeichert hast, und frisch den Cache im Hintergrund auf.
Buttons in den Einstellungen. ctx.settings.add_button("Test", run) setzt einen Button in den Einstellungsdialog des Plugins; run(values) bekommt, was gerade im Dialog steht (gespeichert oder nicht), läuft im Hintergrund und gibt einen Text oder (ok, text) zurück – ✔ grün oder ✖ rot unter den Buttons.
Testen. Das Devkit rendert und klickt wie der Chat: ctx.chat.render(kind, data), ctx.chat.format(text), await ctx.chat.click(href), Antworten für node.ask aus ctx.chat.answers – siehe docs/testing.md.
Ein vollständiges Plugin auf dieser Basis: ElevenLabs im Plugin-Katalog – Stimmen mit ▶-Hörproben im Chat, die Wahl per Nummer oder in eigenen Worten, die erzeugten Audio-Dateien direkt dort abspielbar.