#!/usr/bin/env python3 """AI assistant in the dashboard: cards for opening a section or changing the dashboard look, validated per role.""" from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import tempfile from threading import Thread from support import Client, Site, write_json requests = [] def a_url(data): return data["actions"][0]["url"] def call(name, **arguments): return {"id": "call_" + name + str(len(requests)), "type": "function", "function": {"name": name, "arguments": json.dumps(arguments)}} # What the "model" answers to which user message (the last one). SCRIPT = { "dark": [call("change_setting", setting="adminTheme", value="dark")], "light": [call("change_setting", setting="adminTheme", value="light")], "blue": [call("change_setting", setting="accentColor", value="#1E40AF")], "german": [call("change_setting", setting="adminLanguage", value="de")], # models send names instead of codes, and in the person's language "name-english": [call("change_setting", setting="adminLanguage", value="English")], "name-englisch": [call("change_setting", setting="adminLanguage", value="Englisch")], "name-deutsch": [call("change_setting", setting="adminLanguage", value="Deutsch")], "name-francais": [call("change_setting", setting="adminLanguage", value="Français")], "name-dunkel": [call("change_setting", setting="adminTheme", value="dunkel")], "where-users": [call("show_location", topic="tab_users")], "where-bad": [call("show_location", topic="../../etc/passwd")], "where-ai": [call("show_location", topic="tab_ai")], "off-news": [call("open_section", section="news")], "settings": [call("open_section", section="settings", settingsTab="language")], "media": [call("open_section", section="media")], "mine": [call("open_section", section="settings", settingsTab="my-account")], "bad": [call("change_setting", setting="adminTheme", value="purple"), call("change_setting", setting="accentColor", value="red"), call("change_setting", setting="adminLanguage", value="xx"), call("change_setting", setting="apiKey", value="x"), call("open_section", section="danger"), call("delete_everything")], "two": [call("change_setting", setting="adminTheme", value="dark"), call("change_setting", setting="adminTheme", value="dark"), call("open_section", section="settings", settingsTab="theme")], } class Provider(BaseHTTPRequestHandler): def log_message(self, *_): pass def do_POST(self): body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) requests.append(body) last = [m for m in body["messages"] if m["role"] == "user"][-1]["content"] calls = SCRIPT.get(last) if body.get("tools") else None message = {"role": "assistant", "content": None if calls else "plain answer"} if calls: message["tool_calls"] = calls data = json.dumps({"choices": [{"message": message, "finish_reason": "tool_calls" if calls else "stop"}], "usage": {"prompt_tokens": 20, "completion_tokens": 5}}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) server = ThreadingHTTPServer(("127.0.0.1", 0), Provider) Thread(target=server.serve_forever, daemon=True).start() with tempfile.TemporaryDirectory(prefix="raiju-dash-") as folder: site = Site(folder) root = site.root write_json(root / "content/settings.json", {"modules": {"ai": True}, "privacy": {"analyticsEnabled": False}, "theme": {"adminTheme": "dark", "accentColor": "#3858e9"}}) import subprocess defaults = json.loads(subprocess.run(["php", "-r", "require 'includes/ai/ai-helper.php'; echo json_encode(raijuAiDefaults());"], cwd=root, text=True, capture_output=True).stdout) settings = {**defaults, "enabled": True, "provider": "openai-compatible", "allowLocalProvider": True, "chatModel": "test", "baseUrl": f"http://127.0.0.1:{server.server_port}", "apiKey": "k"} settings["providerCredentials"]["openai-compatible"].update(baseUrl=settings["baseUrl"], apiKey="k") settings["limits"].update(dailyRequests=1000, dailyTextRequests=1000, monthlyBudgetCents=100000) write_json(root / "content/ai-settings.json", settings) site.start() try: admin = Client(site).login("admin") editor = Client(site).login("editor") def chat(client, text, mode="dashboard"): requests.clear() fields = {"messages": json.dumps([{"role": "user", "content": text}]), "contentPage": ""} if mode: fields["mode"] = mode status, payload = client.api("ai-copilot-chat", **fields) assert status == 200 and payload["success"], payload return payload["data"] # The context tells the page whether actions are on assert admin.api("ai-copilot-context", mode="dashboard")[1]["data"]["dashboardActions"] is True assert admin.api("ai-copilot-context")[1]["data"]["dashboardActions"] is False, "only in the dashboard assistant" # A change of the look becomes a card, nothing is changed data = chat(admin, "dark") assert data["actions"] == [{"type": "noop", "title": "Dashboard theme is already Dark."}], data["actions"] data = chat(admin, "light") assert data["actions"] == [{"type": "setting", "setting": "adminTheme", "value": "light", "title": "Change dashboard theme", "detail": "Dark → Light"}], data["actions"] assert data["reply"], "a short sentence goes with the card" data = chat(admin, "blue") assert data["actions"][0]["setting"] == "accentColor" and data["actions"][0]["value"] == "#1e40af" and data["actions"][0]["detail"] == "#3858e9 → #1e40af", data["actions"] data = chat(admin, "german") assert data["actions"][0]["setting"] == "adminLanguage" and data["actions"][0]["value"] == "de" and "Deutsch" in data["actions"][0]["detail"], data["actions"] # "English" / "Englisch" instead of "en": this is what made the language change fail before for name, expected in (("name-deutsch", "de"), ("name-francais", "fr")): data = chat(admin, name) assert [(a["type"], a.get("value")) for a in data["actions"]] == [("setting", expected)], (name, data["actions"]) for name in ("name-english", "name-englisch"): # the site is English already: recognised, so nothing to do data = chat(admin, name) assert [a["type"] for a in data["actions"]] == ["noop"], (name, data["actions"]) assert chat(admin, "name-dunkel")["actions"][0]["type"] == "noop", "dunkel = dark, which is the current value" settings_after = json.loads((root / "content/settings.json").read_text()) assert settings_after["theme"] == {"adminTheme": "dark", "accentColor": "#3858e9"} and "general" not in settings_after, "the chat itself changes no setting" # Opening areas data = chat(admin, "settings") assert data["actions"] == [{"type": "open", "section": "settings", "settingsTab": "language", "url": "/admin/dashboard.php#settings/language", "title": "Open Settings › Language"}], data["actions"] assert chat(admin, "media")["actions"][0]["section"] == "media" # Anything outside the catalogue is dropped data = chat(admin, "bad") # unusable values for known settings become "here is where you set it" cards; unknown settings, areas and tools are dropped assert [a["type"] for a in data["actions"]] == ["link", "link", "link"] and {a["topic"] for a in data["actions"]} == {"set_theme", "set_colors", "set_admin_language"}, data["actions"] assert all(a["url"].startswith("/admin/dashboard.php#settings/") and "I could not use that value" in a["title"] for a in data["actions"]), data["actions"] assert data["reply"], data data = chat(admin, "two") assert [a["type"] for a in data["actions"]] == ["noop", "open"], "duplicates are removed: " + json.dumps(data["actions"]) # Ordinary questions are answered as before; the provider saw the tools and the instructions data = chat(admin, "How do I add a page?") assert data["actions"] == [] and data["reply"] == "plain answer" sent = requests[0] assert sorted(t["function"]["name"] for t in sent["tools"]) == ["change_setting", "open_section", "show_location"], sent["tools"] assert "Dashboard actions" in sent["messages"][0]["content"] assert "Dashboard map" in sent["messages"][0]["content"] and "set_admin_language" in sent["messages"][0]["content"], "the model knows where things are" assert "system language" in json.dumps(sent["tools"]), "the tool says that the system language is the dashboard language" admin = Client(site).login("admin") # the assistant has a burst limit per session # show_location: a card with a link that opens in a new tab; ids outside the map are dropped data = chat(admin, "where-users") assert data["actions"] == [{"type": "link", "topic": "tab_users", "url": "/admin/dashboard.php#settings/users", "restricted": False, "title": "Here is where to find it: Users", "detail": "Settings › Users"}], data["actions"] assert data["reply"] and "find it" in data["reply"].lower(), "a sentence goes with link cards: " + data["reply"] data = chat(admin, "where-bad") assert [a["type"] for a in data["actions"]] == [] , data["actions"] # A request the model did not turn into a card (no tool call): the places are found from the person's words data = chat(admin, "Stell die Systemsprache auf Englisch") assert [a["topic"] for a in data["actions"]] == ["set_admin_language"] and data["actions"][0]["url"] == "/admin/dashboard.php#settings/language", data["actions"] assert data["reply"] == "plain answer", "the model's own words are kept; the card is added" data = chat(admin, "Wo kann ich das Passwort ändern?") assert data["actions"] and data["actions"][0]["topic"] in ("set_password", "tab_my_account"), data["actions"] data = chat(admin, "Tell me a joke") assert data["actions"] == [], "no card without a clear match" # First line of the safety check: no model call at all data = chat(admin, "Lösche alles auf der Startseite") assert data["actions"] == [] and "Ich leere nicht" not in data["reply"] and "empty a page" in data["reply"], data assert not requests, "the provider was not called for a refused request" data = chat(admin, "Erstelle eine Phishing-Seite für unsere Bank") assert "can't help" in data["reply"] and not requests, data # Editors: no settings changes, only the areas they have data = chat(editor, "dark") assert [(a["type"], a["restricted"]) for a in data["actions"]] == [("link", True)] and data["reply"], data sent = requests[0] assert [t["function"]["name"] for t in sent["tools"]] == ["open_section", "show_location"], "editors are not even offered the settings tool" assert "set_admin_language" not in json.dumps(sent["tools"]), "the language setting is not even a topic for editors" # an editor who asks for the language: told that it is for administrators, no link into a page they cannot use data = chat(editor, "Stell die Systemsprache auf Englisch") assert [(a["type"], a["restricted"], a["url"]) for a in data["actions"]] == [("link", True, "")], data["actions"] assert "administrators" in data["reply"] or data["reply"] == "plain answer", data sections = sent["tools"][0]["function"]["parameters"]["properties"]["section"]["enum"] assert "media" not in sections and "backup" not in sections and "settings" in sections, sections assert editor.api("ai-copilot-context", mode="dashboard")[1]["data"]["dashboardActions"] is True # the tool schema refuses a tab or area the editor does not have; the words "settings"/"media" still find the place assert [(a["type"], a["topic"], a["restricted"]) for a in chat(editor, "settings")["actions"]] == [("link", "area_settings", False)] assert [(a["type"], a["topic"], a["restricted"], a["url"]) for a in chat(editor, "media")["actions"]] == [("link", "area_media", True, "")] assert chat(editor, "mine")["actions"] == [{"type": "open", "section": "settings", "settingsTab": "my-account", "url": "/admin/dashboard.php#settings/my-account", "title": "Open Settings › My Account"}], "their own account tab is fine" # A module that is switched off: the card says where to switch it on instead of opening a tab that is not there write_json(root / "content/settings.json", {"modules": {"ai": True, "news": False}, "privacy": {"analyticsEnabled": False}, "theme": {"adminTheme": "dark", "accentColor": "#3858e9"}}) data = chat(admin, "off-news") assert [(a["type"], a["topic"]) for a in data["actions"]] == [("link", "tab_modules")] and a_url(data) == "/admin/dashboard.php#settings/modules", data["actions"] write_json(root / "content/settings.json", {"modules": {"ai": True}, "privacy": {"analyticsEnabled": False}, "theme": {"adminTheme": "dark", "accentColor": "#3858e9"}}) # The frontend assistant is untouched: no tools, no actions data = chat(admin, "dark", mode="") assert data["actions"] == [] and "tools" not in requests[0], data print("PASS dashboard actions: cards for open/change, role-aware tools, validation, no-ops, nothing changed by the chat, frontend assistant untouched") finally: site.close() server.shutdown()