Merge: t-paliad-146 — Paliadin PoC (tmux-Claude in-app AI buddy, m-only)

Phase 0 PoC of the Paliadin design (docs/design-paliadin-2026-05-07.md
§0.5). m-only via in-code email gate (services.PaliadinOwnerEmail);
no deploy-time toggle. tmux-Claude pattern lifted from goldi/mVoice
(mVoice/server.py:250-380). Migration 058 introduces
paliad.paliadin_turns audit table (full prompt+response stored at
PoC scope; production v1 swaps to hash-only). 7 unit tests on the
trailer parser / chip counter / sanitiser, all green.

Surface: /paliadin chat panel (sidebar entry under Übersicht,
revealed by /api/me on owner) + /admin/paliadin monitoring dashboard
(daily counts, classifier histogram, tool-use rate, top prompts,
recent turns). Citation chips parsed from inline marker syntax;
tool-use evidence visible under each bubble.

Production safety: routes register everywhere but the per-request
owner gate returns 404 for any user other than m. paliad.de prod
container has no tmux/claude CLI, so even m hitting the route from
there gets "tmux unavailable" — clear failure, no security surface.

Branch: mai/noether/inventor-paliadin-in-app (8d714dd).
This commit is contained in:
m
2026-05-07 21:57:49 +02:00
20 changed files with 3845 additions and 1 deletions

View File

@@ -0,0 +1,109 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Paliadin monitoring dashboard (t-paliad-146 PoC).
//
// global_admin only. The load-bearing artefact for §0.5.7's expansion
// gate decision: m looks at this every week or two and decides if
// Paliadin earns a production v1 build.
export function renderAdminPaliadin(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.paliadin.title">Paliadin Monitor &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/paliadin" />
<BottomNav currentPath="/admin/paliadin" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.paliadin.heading">Paliadin Monitor</h1>
<p className="tool-subtitle" data-i18n="admin.paliadin.subtitle">
Wie wird Paliadin tats&auml;chlich verwendet?
</p>
</div>
</div>
<div className="paliadin-stats" id="paliadin-stats">
<div className="paliadin-stat-cards">
<div className="paliadin-stat-card">
<div className="paliadin-stat-label" data-i18n="admin.paliadin.total">Gesamt</div>
<div className="paliadin-stat-value" id="stat-total">&mdash;</div>
</div>
<div className="paliadin-stat-card">
<div className="paliadin-stat-label" data-i18n="admin.paliadin.last7">Letzte 7 Tage</div>
<div className="paliadin-stat-value" id="stat-7d">&mdash;</div>
</div>
<div className="paliadin-stat-card">
<div className="paliadin-stat-label" data-i18n="admin.paliadin.median_dur">Median Dauer</div>
<div className="paliadin-stat-value" id="stat-median">&mdash;</div>
</div>
<div className="paliadin-stat-card">
<div className="paliadin-stat-label" data-i18n="admin.paliadin.tool_rate">Tool-Use Rate</div>
<div className="paliadin-stat-value" id="stat-tools">&mdash;</div>
</div>
<div className="paliadin-stat-card">
<div className="paliadin-stat-label" data-i18n="admin.paliadin.abandon_rate">Abbruchrate</div>
<div className="paliadin-stat-value" id="stat-abandon">&mdash;</div>
</div>
</div>
<h2 data-i18n="admin.paliadin.classifier_heading">Anfragearten</h2>
<div className="paliadin-classifier" id="classifier-bars" />
<h2 data-i18n="admin.paliadin.daily_heading">T&auml;gliche Nutzung</h2>
<div className="paliadin-spark" id="daily-spark" />
<h2 data-i18n="admin.paliadin.top_heading">Top Anfragen</h2>
<table className="entity-table entity-table--readonly">
<thead>
<tr>
<th data-i18n="admin.paliadin.col.prompt">Anfrage</th>
<th data-i18n="admin.paliadin.col.count">Anzahl</th>
</tr>
</thead>
<tbody id="top-prompts-tbody">
<tr><td colspan={2} data-i18n="admin.paliadin.loading">Lade &hellip;</td></tr>
</tbody>
</table>
<h2 data-i18n="admin.paliadin.recent_heading">Letzte Anfragen</h2>
<table className="entity-table entity-table--readonly">
<thead>
<tr>
<th data-i18n="admin.paliadin.col.started">Zeit</th>
<th data-i18n="admin.paliadin.col.classifier">Art</th>
<th data-i18n="admin.paliadin.col.prompt">Anfrage</th>
<th data-i18n="admin.paliadin.col.tools">Tools</th>
<th data-i18n="admin.paliadin.col.duration">Dauer</th>
</tr>
</thead>
<tbody id="recent-turns-tbody">
<tr><td colspan={5} data-i18n="admin.paliadin.loading">Lade &hellip;</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/admin-paliadin.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,168 @@
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
// Paliadin admin dashboard client (t-paliad-146 PoC).
//
// Reads /api/admin/paliadin/stats + /api/admin/paliadin/turns and
// renders the cards / bars / sparkline / tables. Pure read-only;
// dashboard refreshes on each visit (no live polling — m comes here
// every few days, not every few seconds).
interface Stats {
total_turns: number;
turns_last_7_days: number;
median_duration_ms: number;
p90_duration_ms: number;
tool_use_rate: number;
abandon_rate: number;
by_classifier: Record<string, number>;
daily_counts: { day: string; count: number }[];
top_prompts: { prompt: string; count: number }[];
}
interface Turn {
turn_id: string;
user_id: string;
started_at: string;
duration_ms: number | null;
user_message: string;
used_tools: string[] | null;
rows_seen: number[] | null;
classifier_tag: string | null;
abandoned: boolean;
error_code: string | null;
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const [stats, turns] = await Promise.all([
fetchJSON<Stats>("/api/admin/paliadin/stats"),
fetchJSON<Turn[]>("/api/admin/paliadin/turns"),
]);
if (stats) renderStats(stats);
if (turns) renderTurns(turns);
});
async function fetchJSON<T>(url: string): Promise<T | null> {
try {
const r = await fetch(url, { credentials: "same-origin" });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
function renderStats(s: Stats): void {
setText("stat-total", String(s.total_turns));
setText("stat-7d", String(s.turns_last_7_days));
setText("stat-median", formatMs(s.median_duration_ms));
setText("stat-tools", formatPct(s.tool_use_rate));
setText("stat-abandon", formatPct(s.abandon_rate));
// Classifier histogram bars.
const cont = document.getElementById("classifier-bars");
if (cont) {
const entries = Object.entries(s.by_classifier).sort((a, b) => b[1] - a[1]);
const max = Math.max(...entries.map((e) => e[1]), 1);
cont.innerHTML = entries
.map(([tag, n]) => {
const pct = (n / max) * 100;
return `<div class="paliadin-classifier-row">
<div class="paliadin-classifier-label">${escapeHTML(tag)}</div>
<div class="paliadin-classifier-bar"><div class="paliadin-classifier-fill" style="width:${pct}%"></div></div>
<div class="paliadin-classifier-count">${n}</div>
</div>`;
})
.join("");
}
// Daily sparkline (last 30 days, vertical bars).
const spark = document.getElementById("daily-spark");
if (spark) {
const days = s.daily_counts;
const max = Math.max(...days.map((d) => d.count), 1);
spark.innerHTML = days
.map((d) => {
const h = (d.count / max) * 60;
return `<div class="paliadin-spark-bar" style="height:${h}px" title="${d.day}: ${d.count}"></div>`;
})
.join("");
}
// Top prompts table.
const tbody = document.getElementById("top-prompts-tbody");
if (tbody) {
if (s.top_prompts.length === 0) {
tbody.innerHTML = `<tr><td colspan="2">Noch keine Daten.</td></tr>`;
} else {
tbody.innerHTML = s.top_prompts
.map(
(p) =>
`<tr><td>${escapeHTML(p.prompt)}</td><td>${p.count}</td></tr>`,
)
.join("");
}
}
}
function renderTurns(turns: Turn[]): void {
const tbody = document.getElementById("recent-turns-tbody");
if (!tbody) return;
if (turns.length === 0) {
tbody.innerHTML = `<tr><td colspan="5">Noch keine Anfragen.</td></tr>`;
return;
}
tbody.innerHTML = turns
.map((t) => {
const tag = t.classifier_tag || "—";
const tools = t.used_tools && t.used_tools.length > 0
? t.used_tools.join(", ")
: "—";
const dur = t.duration_ms != null ? formatMs(t.duration_ms) : "—";
const errMark = t.error_code ? `${t.error_code}` : "";
return `<tr>
<td>${formatTime(t.started_at)}</td>
<td>${escapeHTML(tag)}</td>
<td>${escapeHTML(truncate(t.user_message, 120))}${errMark}</td>
<td>${escapeHTML(tools)}</td>
<td>${dur}</td>
</tr>`;
})
.join("");
}
function setText(id: string, val: string): void {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
function formatMs(ms: number): string {
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(1)} s`;
}
function formatPct(r: number): string {
return `${Math.round(r * 100)} %`;
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString();
}
function truncate(s: string, n: number): string {
if (s.length <= n) return s;
return s.slice(0, n - 1) + "…";
}
function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

View File

@@ -35,6 +35,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.dashboard": "Dashboard",
"nav.agenda": "Agenda",
"nav.inbox": "Genehmigungen",
"nav.paliadin": "Paliadin",
"nav.team": "Team",
"nav.group.uebersicht": "\u00dcbersicht",
"nav.group.arbeit": "Arbeit",
@@ -1444,6 +1445,40 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.broadcasts.detail.delivered": "versandt",
"admin.broadcasts.detail.failed": "fehlgeschlagen",
"admin.broadcasts.detail.recipients": "Empfänger",
// t-paliad-146: Paliadin in-app AI buddy (PoC)
"paliadin.title": "Paliadin — Paliad",
"paliadin.heading": "✨ Paliadin",
"paliadin.tagline": "Ich kenne deine Akten und Paliads Wissensbasis.",
"paliadin.empty": "Was kann ich für dich tun?",
"paliadin.starter.today": "Was steht heute an?",
"paliadin.starter.week": "Welche Fristen sind diese Woche fällig?",
"paliadin.starter.concept": "Erkläre mir Klageerwiderung.",
"paliadin.input.placeholder": "Frag den Paliadin…",
"paliadin.send": "Senden",
"paliadin.stop": "Stop",
"paliadin.reset": "Neue Unterhaltung",
"nav.admin.paliadin": "Paliadin Monitor",
"admin.paliadin.title": "Paliadin Monitor — Paliad",
"admin.paliadin.heading": "Paliadin Monitor",
"admin.paliadin.subtitle": "Wie wird Paliadin tatsächlich verwendet?",
"admin.paliadin.total": "Gesamt",
"admin.paliadin.last7": "Letzte 7 Tage",
"admin.paliadin.median_dur": "Median Dauer",
"admin.paliadin.tool_rate": "Tool-Use Rate",
"admin.paliadin.abandon_rate": "Abbruchrate",
"admin.paliadin.classifier_heading": "Anfragearten",
"admin.paliadin.daily_heading": "Tägliche Nutzung",
"admin.paliadin.top_heading": "Top Anfragen",
"admin.paliadin.recent_heading": "Letzte Anfragen",
"admin.paliadin.col.prompt": "Anfrage",
"admin.paliadin.col.count": "Anzahl",
"admin.paliadin.col.started": "Zeit",
"admin.paliadin.col.classifier": "Art",
"admin.paliadin.col.tools": "Tools",
"admin.paliadin.col.duration": "Dauer",
"admin.paliadin.loading": "Lade…",
"common.forbidden": "Zugriff verweigert.",
"common.load_error": "Fehler beim Laden.",
"common.loading": "Lade…",
@@ -1902,6 +1937,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.dashboard": "Dashboard",
"nav.agenda": "Agenda",
"nav.inbox": "Approvals",
"nav.paliadin": "Paliadin",
"nav.team": "Team",
"nav.group.uebersicht": "Overview",
"nav.group.arbeit": "Work",
@@ -3299,6 +3335,40 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.broadcasts.detail.delivered": "delivered",
"admin.broadcasts.detail.failed": "failed",
"admin.broadcasts.detail.recipients": "Recipients",
// t-paliad-146: Paliadin in-app AI buddy (PoC)
"paliadin.title": "Paliadin — Paliad",
"paliadin.heading": "✨ Paliadin",
"paliadin.tagline": "I know your matters and Paliad's knowledge base.",
"paliadin.empty": "What can I help you with?",
"paliadin.starter.today": "What's on my plate today?",
"paliadin.starter.week": "Which deadlines are due this week?",
"paliadin.starter.concept": "Explain Klageerwiderung.",
"paliadin.input.placeholder": "Ask Paliadin…",
"paliadin.send": "Send",
"paliadin.stop": "Stop",
"paliadin.reset": "New conversation",
"nav.admin.paliadin": "Paliadin Monitor",
"admin.paliadin.title": "Paliadin Monitor — Paliad",
"admin.paliadin.heading": "Paliadin Monitor",
"admin.paliadin.subtitle": "How is Paliadin actually being used?",
"admin.paliadin.total": "Total",
"admin.paliadin.last7": "Last 7 days",
"admin.paliadin.median_dur": "Median duration",
"admin.paliadin.tool_rate": "Tool-use rate",
"admin.paliadin.abandon_rate": "Abandon rate",
"admin.paliadin.classifier_heading": "Question types",
"admin.paliadin.daily_heading": "Daily usage",
"admin.paliadin.top_heading": "Top queries",
"admin.paliadin.recent_heading": "Recent queries",
"admin.paliadin.col.prompt": "Query",
"admin.paliadin.col.count": "Count",
"admin.paliadin.col.started": "Time",
"admin.paliadin.col.classifier": "Type",
"admin.paliadin.col.tools": "Tools",
"admin.paliadin.col.duration": "Duration",
"admin.paliadin.loading": "Loading…",
"common.forbidden": "Access denied.",
"common.load_error": "Load error.",
"common.loading": "Loading…",

View File

@@ -0,0 +1,383 @@
import { initI18n, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// Paliadin chat panel client (t-paliad-146 PoC).
//
// State machine: empty → typing → sending → streaming → done.
// History lives in localStorage under "paliadin:history:<sessionId>"
// — design §0.5.4 session-only persistence.
//
// SSE consumer subscribes to `event: meta`, `event: content`,
// `event: end`, `event: error`, `event: ping`. Backend currently
// emits one `content` blob per turn (real chunked streaming is
// production-v1; PoC simulates with a typewriter effect).
interface HistoryEntry {
role: "user" | "assistant";
text: string;
meta?: {
used_tools?: string[];
rows_seen?: number[];
classifier_tag?: string;
duration_ms?: number;
chip_count?: number;
};
ts: string; // ISO
}
const SESSION_KEY = "paliadin:session";
const HISTORY_PREFIX = "paliadin:history:";
let sessionId: string;
let history: HistoryEntry[] = [];
let currentEventSource: EventSource | null = null;
let currentTurnId: string | null = null;
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
bootSession();
wireForm();
wireStarters();
wireReset();
renderHistory();
});
function bootSession(): void {
let s = localStorage.getItem(SESSION_KEY);
if (!s) {
s = crypto.randomUUID();
localStorage.setItem(SESSION_KEY, s);
}
sessionId = s;
const stored = localStorage.getItem(HISTORY_PREFIX + sessionId);
if (stored) {
try {
history = JSON.parse(stored);
} catch {
history = [];
}
}
}
function wireForm(): void {
const form = document.getElementById("paliadin-form") as HTMLFormElement | null;
const input = document.getElementById("paliadin-input") as HTMLTextAreaElement | null;
if (!form || !input) return;
form.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
input.value = "";
sendTurn(text);
});
// Enter sends; Shift+Enter inserts newline.
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
form.dispatchEvent(new Event("submit"));
}
});
}
function wireStarters(): void {
const starters = document.querySelectorAll<HTMLButtonElement>(".paliadin-starter");
starters.forEach((btn) => {
btn.addEventListener("click", () => {
const lang = getLang();
const promptText = lang === "en"
? btn.dataset.promptEn || btn.textContent?.trim() || ""
: btn.dataset.promptDe || btn.textContent?.trim() || "";
if (promptText) sendTurn(promptText);
});
});
}
function wireReset(): void {
const btn = document.getElementById("paliadin-reset");
if (!btn) return;
btn.addEventListener("click", async () => {
history = [];
saveHistory();
renderHistory();
try {
await fetch("/api/paliadin/reset", { method: "POST", credentials: "same-origin" });
} catch {
// Reset failure is non-fatal — the next turn will spin up a fresh pane anyway.
}
});
}
async function sendTurn(text: string): Promise<void> {
// Hide empty state on first send.
const empty = document.getElementById("paliadin-empty");
if (empty) empty.style.display = "none";
// Append user bubble.
history.push({ role: "user", text, ts: new Date().toISOString() });
saveHistory();
appendBubble("user", text);
// Insert placeholder assistant bubble.
const placeholder = appendBubble("assistant", "");
placeholder.dataset.streaming = "true";
placeholder.querySelector(".paliadin-bubble-text")!.textContent = "Paliadin denkt nach …";
toggleStopButton(true);
// Kick off the turn.
let turnRes: { turn_id: string; sse_url: string };
try {
const r = await fetch("/api/paliadin/turn", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
user_message: text,
session_id: sessionId,
page_origin: "/paliadin",
}),
});
if (!r.ok) throw new Error("HTTP " + r.status);
turnRes = await r.json();
} catch (err) {
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
"Fehler beim Senden: " + String(err);
placeholder.dataset.streaming = "false";
placeholder.classList.add("paliadin-bubble--error");
toggleStopButton(false);
return;
}
currentTurnId = turnRes.turn_id;
// Open SSE.
const es = new EventSource(turnRes.sse_url);
currentEventSource = es;
es.addEventListener("meta", () => {
// Could surface a "thinking" indicator; placeholder text already does.
});
es.addEventListener("content", (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
const text = String(data.text || "");
typewriter(placeholder, text);
});
es.addEventListener("end", (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
placeholder.dataset.streaming = "false";
finishBubble(placeholder, data);
history.push({
role: "assistant",
text: getBubbleText(placeholder),
meta: {
used_tools: data.used_tools,
rows_seen: data.rows_seen,
classifier_tag: data.classifier_tag,
duration_ms: data.duration_ms,
chip_count: data.chip_count,
},
ts: new Date().toISOString(),
});
saveHistory();
cleanupTurn();
});
es.addEventListener("error", (ev) => {
const msg = (ev as MessageEvent).data
? "Fehler: " + (ev as MessageEvent).data
: "Verbindung verloren.";
placeholder.querySelector(".paliadin-bubble-text")!.textContent = msg;
placeholder.classList.add("paliadin-bubble--error");
placeholder.dataset.streaming = "false";
cleanupTurn();
});
es.addEventListener("ping", () => {
// heartbeat — no-op
});
}
function cleanupTurn(): void {
if (currentEventSource) {
currentEventSource.close();
currentEventSource = null;
}
currentTurnId = null;
toggleStopButton(false);
}
function toggleStopButton(streaming: boolean): void {
const send = document.getElementById("paliadin-send") as HTMLButtonElement | null;
const stop = document.getElementById("paliadin-stop") as HTMLButtonElement | null;
if (send) send.style.display = streaming ? "none" : "";
if (stop) {
stop.style.display = streaming ? "" : "none";
stop.onclick = () => {
cleanupTurn();
};
}
}
function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
const stream = document.getElementById("paliadin-stream")!;
const bubble = document.createElement("div");
bubble.className = "paliadin-bubble paliadin-bubble--" + role;
bubble.innerHTML = `
<div class="paliadin-bubble-role">${role === "user" ? "Du" : "Paliadin"}</div>
<div class="paliadin-bubble-text"></div>
<div class="paliadin-bubble-meta" style="display:none"></div>
`;
bubble.querySelector(".paliadin-bubble-text")!.textContent = text;
stream.appendChild(bubble);
stream.scrollTop = stream.scrollHeight;
return bubble;
}
// typewriter incrementally fills the bubble's text node so a one-shot
// content blob feels like streaming. ~5 ms per character; fast enough
// to keep up with even a 4k-char response.
function typewriter(bubble: HTMLElement, text: string): void {
const node = bubble.querySelector(".paliadin-bubble-text")!;
node.textContent = "";
let i = 0;
const speed = 6;
const tick = () => {
if (bubble.dataset.streaming !== "true") {
// Aborted — flush remaining text instantly.
node.textContent = text;
return;
}
if (i >= text.length) return;
const next = Math.min(i + 8, text.length);
node.textContent = text.slice(0, next);
i = next;
const stream = document.getElementById("paliadin-stream")!;
stream.scrollTop = stream.scrollHeight;
setTimeout(tick, speed);
};
tick();
}
function getBubbleText(bubble: HTMLElement): string {
return bubble.querySelector(".paliadin-bubble-text")?.textContent || "";
}
// finishBubble parses the response for citation markers + tool-use
// evidence and renders both. Markers found in the text get replaced
// by anchor buttons; the meta row at the bottom shows
// "ran search_my_deadlines (3 results)".
function finishBubble(bubble: HTMLElement, data: any): void {
const textNode = bubble.querySelector(".paliadin-bubble-text")! as HTMLElement;
const raw = textNode.textContent || "";
textNode.innerHTML = renderResponseHTML(raw);
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
if (metaEl) {
const tools = (data.used_tools || []) as string[];
const rows = (data.rows_seen || []) as number[];
if (tools.length > 0) {
const parts = tools.map((t, i) => {
const r = rows[i];
return r != null ? `${t} (${r})` : t;
});
metaEl.innerHTML = "▸ " + parts.join(" · ");
metaEl.style.display = "";
}
}
}
// Marker → button render. Mirrors §4.4 of the design.
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
function renderResponseHTML(raw: string): string {
// First escape any HTML in the raw text (simple textContent → innerHTML
// would have been fine but we then need to inject anchors, so the
// manual escape is unavoidable).
const esc = raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
// Walk markers; replace each with a paliadin-chip anchor.
return esc.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
if (kind && id) {
const url = chipURL(kind, id);
const label = chipLabel(kind);
return `<a class="paliadin-chip" href="${url}">${label}</a>`;
}
if (chipKind === "nav") {
return `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
}
if (chipKind === "filter") {
return `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
}
return "";
});
}
function chipURL(kind: string, id: string): string {
switch (kind) {
case "deadline":
case "frist":
return "/deadlines/" + id;
case "projekt":
case "project":
return "/projects/" + id;
case "termin":
case "appointment":
return "/appointments/" + id;
default:
return "#";
}
}
function chipLabel(kind: string): string {
switch (kind) {
case "deadline":
case "frist":
return "Frist öffnen";
case "projekt":
case "project":
return "Akte ansehen";
case "termin":
case "appointment":
return "Termin öffnen";
default:
return "öffnen";
}
}
function saveHistory(): void {
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history));
}
function renderHistory(): void {
const stream = document.getElementById("paliadin-stream");
if (!stream) return;
// Clear non-empty bubbles, keep the empty-state.
Array.from(stream.children).forEach((el) => {
if (!el.classList.contains("paliadin-empty")) el.remove();
});
if (history.length === 0) {
const empty = document.getElementById("paliadin-empty");
if (empty) empty.style.display = "";
return;
}
const empty = document.getElementById("paliadin-empty");
if (empty) empty.style.display = "none";
history.forEach((h) => {
const bubble = appendBubble(h.role, h.text);
if (h.role === "assistant" && h.meta) {
bubble.dataset.streaming = "false";
finishBubble(bubble, {
used_tools: h.meta.used_tools,
rows_seen: h.meta.rows_seen,
});
}
});
}

View File

@@ -72,6 +72,7 @@ export function initSidebar() {
initChangelogBadge();
initInboxBadge();
initAdminGroup();
initPaliadinLinks();
initUserViewsGroup();
initThemeToggle();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
@@ -517,6 +518,32 @@ function userViewIconSvg(icon?: string): string {
}
}
// PALIADIN_OWNER_EMAIL must match services.PaliadinOwnerEmail (Go side).
// PoC scope — see docs/design-paliadin-2026-05-07.md §0.5.
const PALIADIN_OWNER_EMAIL = "matthias.siebels@hoganlovells.com";
// initPaliadinLinks reveals the Paliadin sidebar entries (under Übersicht
// + Admin) when /api/me confirms the caller is the Paliadin owner. Same
// fail-closed display:none pattern as initAdminGroup. Non-owners never
// see the entries; the routes themselves return 404 if they navigate
// to /paliadin or /admin/paliadin manually anyway.
function initPaliadinLinks(): void {
const top = document.getElementById("sidebar-paliadin-link") as HTMLElement | null;
const admin = document.getElementById("sidebar-admin-paliadin-link") as HTMLElement | null;
if (!top && !admin) return;
fetch("/api/me", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((me: { email?: string } | null) => {
if (me && me.email && me.email.toLowerCase() === PALIADIN_OWNER_EMAIL) {
if (top) top.style.display = "";
if (admin) admin.style.display = "";
}
})
.catch(() => {
// silent: failing closed is the safe default.
});
}
// initAdminGroup reveals the Admin section in the sidebar when the caller's
// /api/me lookup confirms global_role='global_admin'. The markup is in the
// DOM with display:none for everyone — flipping it on after the fetch lands

View File

@@ -116,6 +116,14 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) +
navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath) +
navItem("/inbox", ICON_BELL, "nav.inbox", "Inbox", currentPath, "sidebar-inbox-badge") +
// Paliadin entry \u2014 owner-only, hidden by default. sidebar.ts
// reveals it after /api/me confirms the caller is the
// Paliadin owner (t-paliad-146 PoC scope). Same fail-closed
// pattern as the admin group below.
`<a href="/paliadin" class="sidebar-item sidebar-paliadin${currentPath === "/paliadin" ? " active" : ""}" id="sidebar-paliadin-link" style="display:none">` +
`<span class="sidebar-icon">${ICON_SPARKLE}</span>` +
`<span class="sidebar-label" data-i18n="nav.paliadin">Paliadin</span>` +
`</a>` +
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
)}
@@ -171,6 +179,13 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}
style="display:none">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
<span className="sidebar-label" data-i18n="nav.admin.paliadin">Paliadin Monitor</span>
</a>
</div>
</nav>

View File

@@ -165,6 +165,25 @@ export type I18nKey =
| "admin.event_types.subtitle"
| "admin.event_types.title"
| "admin.heading"
| "admin.paliadin.abandon_rate"
| "admin.paliadin.classifier_heading"
| "admin.paliadin.col.classifier"
| "admin.paliadin.col.count"
| "admin.paliadin.col.duration"
| "admin.paliadin.col.prompt"
| "admin.paliadin.col.started"
| "admin.paliadin.col.tools"
| "admin.paliadin.daily_heading"
| "admin.paliadin.heading"
| "admin.paliadin.last7"
| "admin.paliadin.loading"
| "admin.paliadin.median_dur"
| "admin.paliadin.recent_heading"
| "admin.paliadin.subtitle"
| "admin.paliadin.title"
| "admin.paliadin.tool_rate"
| "admin.paliadin.top_heading"
| "admin.paliadin.total"
| "admin.partner_units.action.delete"
| "admin.partner_units.action.edit"
| "admin.partner_units.action.members"
@@ -1294,6 +1313,7 @@ export type I18nKey =
| "nav.admin.audit"
| "nav.admin.bereich"
| "nav.admin.event_types"
| "nav.admin.paliadin"
| "nav.admin.partner_units"
| "nav.admin.team"
| "nav.agenda"
@@ -1322,6 +1342,7 @@ export type I18nKey =
| "nav.links"
| "nav.logout"
| "nav.neuigkeiten"
| "nav.paliadin"
| "nav.projekte"
| "nav.soon.tooltip"
| "nav.team"
@@ -1394,6 +1415,17 @@ export type I18nKey =
| "palette.footer.navigate"
| "palette.footer.open"
| "palette.section.actions"
| "paliadin.empty"
| "paliadin.heading"
| "paliadin.input.placeholder"
| "paliadin.reset"
| "paliadin.send"
| "paliadin.starter.concept"
| "paliadin.starter.today"
| "paliadin.starter.week"
| "paliadin.stop"
| "paliadin.tagline"
| "paliadin.title"
| "partner_unit.heading"
| "partner_unit.members_label"
| "partner_unit.none"

97
frontend/src/paliadin.tsx Normal file
View File

@@ -0,0 +1,97 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Paliadin chat panel page (t-paliad-146 PoC).
//
// Single full-page surface; m types into an input at the bottom, sees
// a stream of bubbles above. Server-side hydration is deliberately
// minimal — the panel boots empty, the client manages state in
// localStorage (per design §0.5.4 session-only history).
export function renderPaliadin(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="paliadin.title">Paliadin &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/paliadin" />
<BottomNav currentPath="/paliadin" />
<main>
<section className="tool-page paliadin-page">
<div className="container paliadin-container">
<div className="tool-header paliadin-header">
<div>
<h1 data-i18n="paliadin.heading">&#x2728; Paliadin</h1>
<p className="tool-subtitle paliadin-tagline" data-i18n="paliadin.tagline">
Ich kenne deine Akten und Paliads Wissensbasis.
</p>
</div>
<button type="button" className="btn-secondary paliadin-reset" id="paliadin-reset"
data-i18n="paliadin.reset">
Neue Unterhaltung
</button>
</div>
<div className="paliadin-stream" id="paliadin-stream" aria-live="polite">
<div className="paliadin-empty" id="paliadin-empty">
<p data-i18n="paliadin.empty">Was kann ich f&uuml;r dich tun?</p>
<div className="paliadin-starters" id="paliadin-starters">
<button type="button" className="paliadin-starter"
data-prompt-de="Was steht heute an?"
data-prompt-en="What's on my plate today?"
data-i18n="paliadin.starter.today">
Was steht heute an?
</button>
<button type="button" className="paliadin-starter"
data-prompt-de="Welche Fristen sind diese Woche f&auml;llig?"
data-prompt-en="Which deadlines are due this week?"
data-i18n="paliadin.starter.week">
Welche Fristen sind diese Woche f&auml;llig?
</button>
<button type="button" className="paliadin-starter"
data-prompt-de="Erkl&auml;re mir Klageerwiderung."
data-prompt-en="Explain Klageerwiderung."
data-i18n="paliadin.starter.concept">
Erkl&auml;re mir Klageerwiderung.
</button>
</div>
</div>
</div>
<form className="paliadin-form" id="paliadin-form">
<textarea className="paliadin-input" id="paliadin-input"
rows={2}
data-i18n-placeholder="paliadin.input.placeholder"
placeholder="Frag den Paliadin&hellip;"
required></textarea>
<button type="submit" className="btn-primary paliadin-send" id="paliadin-send"
data-i18n="paliadin.send">
Senden
</button>
<button type="button" className="btn-secondary paliadin-stop" id="paliadin-stop"
style="display:none"
data-i18n="paliadin.stop">
Stop
</button>
</form>
</div>
</section>
</main>
<Footer />
<script src="/assets/paliadin.js"></script>
</body>
</html>
);
}

View File

@@ -11012,3 +11012,258 @@ dialog.quick-add-sheet::backdrop {
}
}
/* ============================================================================
* t-paliad-146 — Paliadin in-app AI buddy (PoC).
* ========================================================================== */
.paliadin-page {
padding-bottom: 0;
}
.paliadin-container {
display: flex;
flex-direction: column;
height: calc(100vh - var(--page-chrome-h, 80px));
max-height: calc(100vh - 80px);
}
.paliadin-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-shrink: 0;
}
.paliadin-tagline {
font-size: 0.95rem;
color: var(--color-text-muted);
}
.paliadin-reset {
flex-shrink: 0;
}
.paliadin-stream {
flex: 1 1 auto;
overflow-y: auto;
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 200px;
}
.paliadin-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
text-align: center;
color: var(--color-text-muted);
padding: 2rem;
}
.paliadin-empty p {
font-size: 1.2rem;
margin-bottom: 1.5rem;
}
.paliadin-starters {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 480px;
}
.paliadin-starter {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
padding: 0.75rem 1rem;
border-radius: 8px;
cursor: pointer;
text-align: left;
font-size: 0.95rem;
}
.paliadin-starter:hover {
background: var(--color-surface-hover, var(--color-surface));
border-color: var(--color-accent);
}
.paliadin-bubble {
max-width: 85%;
padding: 0.75rem 1rem;
border-radius: 12px;
line-height: 1.5;
word-wrap: break-word;
}
.paliadin-bubble--user {
align-self: flex-end;
background: var(--color-accent-tint, #e8fbb2);
border: 1px solid var(--color-accent);
}
.paliadin-bubble--assistant {
align-self: flex-start;
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.paliadin-bubble--error {
border-color: var(--color-status-red, #c54);
background: var(--color-status-red-tint, #fee);
}
.paliadin-bubble-role {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
}
.paliadin-bubble-text {
white-space: pre-wrap;
}
.paliadin-bubble-meta {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
font-family: monospace;
}
.paliadin-chip {
display: inline-block;
background: var(--color-accent-tint, #e8fbb2);
border: 1px solid var(--color-accent);
color: var(--color-text);
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.85rem;
text-decoration: none;
margin: 0 0.15rem;
cursor: pointer;
}
.paliadin-chip:hover {
background: var(--color-accent);
}
.paliadin-form {
display: flex;
gap: 0.5rem;
padding: 0.75rem 0;
flex-shrink: 0;
border-top: 1px solid var(--color-border);
}
.paliadin-input {
flex: 1;
resize: vertical;
min-height: 2.5rem;
max-height: 12rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 1rem;
}
.paliadin-input:focus {
outline: none;
border-color: var(--color-accent);
}
/* /admin/paliadin dashboard. */
.paliadin-stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
margin: 1.5rem 0 2rem;
}
.paliadin-stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem;
}
.paliadin-stat-label {
font-size: 0.85rem;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.paliadin-stat-value {
font-size: 1.75rem;
font-weight: 600;
color: var(--color-text);
}
.paliadin-classifier {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1rem 0 2rem;
max-width: 600px;
}
.paliadin-classifier-row {
display: grid;
grid-template-columns: 6rem 1fr 3rem;
align-items: center;
gap: 0.75rem;
}
.paliadin-classifier-label {
font-size: 0.9rem;
color: var(--color-text);
}
.paliadin-classifier-bar {
background: var(--color-surface);
border: 1px solid var(--color-border);
height: 1.25rem;
border-radius: 4px;
overflow: hidden;
}
.paliadin-classifier-fill {
height: 100%;
background: var(--color-accent);
}
.paliadin-classifier-count {
font-family: monospace;
text-align: right;
color: var(--color-text-muted);
}
.paliadin-spark {
display: flex;
align-items: flex-end;
gap: 2px;
height: 60px;
margin: 1rem 0 2rem;
padding: 0 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.paliadin-spark-bar {
flex: 1;
background: var(--color-accent);
min-height: 1px;
max-width: 12px;
}