/* =========================================================
CONNIE — panels (left + right column cards)
========================================================= */
const { useEffect: pEffect, useRef: pRef, useState: pState } = React;
// ----- Inline icon set (Lucide-styled outline, 1.75 stroke) -----
const ICON_PATHS = {
menu: <> >,
mic: <> >,
square: ,
search: <> >,
mail: <> >,
sun: <> >,
presentation: <> >,
"arrow-up": <> >,
check: ,
"loader-2": <> >,
bot: <> >,
// dashboard-v3 controls
settings: <> >,
"eye-off": <> >,
"chevron-up": ,
"chevron-down": ,
"chevron-left": ,
"chevron-right": ,
"minimize-2": <> >,
"maximize-2": <> >,
x: <> >,
"volume-2": <> >,
"volume-x": <> >,
film: <> >,
upload: <> >,
image: <> >,
eye: <> >,
// dashboard-v3.1: drag handle (6-dot grip) + media controls for voice preview
grip: <> >,
play: ,
pause: <> >,
// per-user-visibility UI: calendar section title icon
calendar: <> >,
// Standort (S2a) section title icon
"map-pin": <> >,
// Aktien-Portfolio: card title + per-line trend + add/remove controls
"trending-up": <> >,
"trending-down": <> >,
"line-chart": <> >,
plus: <> >,
trash: <> >,
// Wissen (Knowledge-Input, S4) section title icon
"book-open": <> >,
// Auth-Login slice (WB-Auth, 2026-06-04): logout button in topbar.
"log-out": <> >,
};
// Group the flat knowledge-fact list for display (File-Upload extension): facts
// that share a ``source_id`` (the chunks of one uploaded file) collapse into a
// single entry ("N Fakten" + a preview); manually-typed facts (no source_id)
// stay individual. Order is preserved from the (newest-first) input. Pure
// function (exported on window for the JSX-parse + a future logic test).
function groupKnowledge(facts) {
const out = [];
const fileGroups = {}; // source_id -> out-index
for (const f of Array.isArray(facts) ? facts : []) {
if (!f || typeof f !== "object") continue;
const sid = typeof f.source_id === "string" && f.source_id ? f.source_id : null;
if (sid && f.source === "upload") {
if (fileGroups[sid] === undefined) {
fileGroups[sid] = out.length;
out.push({
key: "file:" + sid,
isFile: true,
sourceId: sid,
title: f.title || null,
topic: f.topic || null,
created_by: f.created_by || null,
count: 1,
// Preview = the first chunk (chunk_index 0 sorts first server-side).
preview: typeof f.body === "string" ? f.body : "",
});
} else {
out[fileGroups[sid]].count += 1;
}
} else {
out.push({
key: "fact:" + (f.id || Math.random().toString(36).slice(2)),
isFile: false,
id: f.id || null,
title: f.title || null,
topic: f.topic || null,
created_by: f.created_by || null,
count: 1,
preview: typeof f.body === "string" ? f.body : "",
});
}
}
return out;
}
function Icon({ name, size = 16, className = "", strokeWidth = 1.75 }) {
return (
{ICON_PATHS[name] || null}
);
}
// ============================================================
// HEUTE — schedule card (live: items from GET /web/context)
// ============================================================
const WEEKDAYS = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
// Small dim placeholder used by the live cards while loading or on error.
function CardPlaceholder({ text }) {
return (
{text}
);
}
function ScheduleCard({ items }) {
const weekday = WEEKDAYS[new Date().getDay()];
const loading = items === null; // not loaded yet
const list = Array.isArray(items) ? items : [];
const meta = loading
? "lade ..."
: `${list.length} ${list.length === 1 ? "Termin" : "Termine"}`;
return (
{loading &&
}
{!loading && list.length === 0 &&
}
{!loading && list.map((a, i) => (
{a.time}
{a.title}
{a.sub &&
{a.sub}
}
))}
);
}
// ============================================================
// PIPELINE — HubSpot stats
// ============================================================
function Sparkline({ data, color = "#05E6A5" }) {
const w = 100, h = 28;
const max = Math.max(...data), min = Math.min(...data);
const range = max - min || 1;
const points = data
.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - ((v - min) / range) * (h - 4) - 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
);
}
// One open deal as a row. With a backend-built url it's a clickable deep-link
// (opens the HubSpot record in a new tab); without url it's plain text (the
// backend drops url when portal_id is unresolved - graceful, F2 / SR-DV-11).
function DealRow({ deal }) {
const label = deal.name || "(kein Name)";
const stage = deal.stage && deal.stage !== "-" ? deal.stage : "";
const inner = (
<>
{label}
{stage && {stage} }
>
);
if (deal.url) {
return (
{inner}
);
}
return {inner}
;
}
function PipelineCard({ data }) {
const [modal, setModal] = pState(false); // Wiedervorlage layover
const loading = data === undefined || data === null;
const openN = loading ? "–" : (data.open_deals ?? 0);
const followN = loading ? "–" : (data.needs_followup ?? 0);
const headline = loading
? "lade Pipeline ..."
: (data.headline || "Keine Pipeline-Daten.");
const topDeals = (!loading && Array.isArray(data.top_deals)) ? data.top_deals : [];
// #6: deals parked on Wiedervorlage (future next-activity date) are NOT in
// topDeals; they live here and open in a layover when the stat is clicked.
const followups = (!loading && Array.isArray(data.followups)) ? data.followups : [];
const canOpen = followups.length > 0;
// Escape closes the Wiedervorlage layover.
pEffect(() => {
if (!modal) return;
const onKey = (e) => { if (e.key === "Escape") setModal(false); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [modal]);
const rowStyle = {
display: "flex", justifyContent: "space-between", gap: 14, alignItems: "baseline",
padding: "7px 0", borderBottom: "1px solid var(--line)",
};
const dateStyle = { fontFamily: "var(--font-mono)", fontSize: 12, color: "#FFB020", whiteSpace: "nowrap", flexShrink: 0 };
const stageStyle = { color: "var(--fg-3)", fontSize: 12 };
return (
Pipeline · HubSpot
{loading ? "lade ..." : "letzte 30 Tage"}
{openN}
Offene Deals
{/* Decorative spark - no historical series is exposed by the API. */}
setModal(true) : undefined}
role={canOpen ? "button" : undefined}
tabIndex={canOpen ? 0 : undefined}
onKeyDown={canOpen ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setModal(true); } } : undefined}
title={canOpen ? "Wiedervorlagen ansehen" : "Keine Wiedervorlagen"}
style={{ cursor: canOpen ? "pointer" : "default" }}
>
{followN}
Wiedervorlage{canOpen ? " ›" : ""}
{topDeals.length > 0 && (
{topDeals.map((d) => )}
)}
{headline}
{modal && ReactDOM.createPortal(
setModal(false)} role="dialog" aria-modal="true">
e.stopPropagation()}>
Wiedervorlage
setModal(false)} aria-label="Schließen">
Deals mit einem Folgetermin in der Zukunft - bis dahin geparkt, daher
nicht in der Live-Liste.
{followups.map((f) => (
{f.url
?
{f.name}
:
{f.name} }
{f.stage && f.stage !== "-" &&
· {f.stage} }
{f.date || ""}
))}
setModal(false)}>Schließen
,
document.body
)}
);
}
// ============================================================
// GEDÄCHTNIS — knowledge graph card
// ============================================================
// Truncate a node title for the small SVG label (keeps whole words where easy).
function shortLabel(s, max = 14) {
const t = String(s || "");
return t.length > max ? t.slice(0, max - 1) + "…" : t;
}
// Colour a node by its kind. Focus node = lime (CONVENTIC primary), neighbours
// teal (secondary). Tokens mirror styles.css (#E1F528 / #05E6A5).
function nodeColor(isFocus) {
return isFocus ? "#E1F528" : "#05E6A5";
}
// Data-driven, hand-rolled radial layout (no library, SR-VM-10). The focus node
// sits at the centre; neighbours are placed on a circle at deterministic angles
// (no animation -> reduced-motion-friendly). Clicking a neighbour re-centres via
// onFocusNode(focusString). Labels are React text nodes (no innerHTML, SR-DV-9).
function MemoryGraph({ graph, onFocusNode }) {
const W = 100, H = 72, cx = 50, cy = 36;
const nodes = (graph && Array.isArray(graph.nodes)) ? graph.nodes : [];
const focusId = graph ? graph.focus_id : null;
if (!nodes.length) {
// No subgraph yet (no focus / empty result): keep a calm placeholder.
return (
);
}
const focusNode = nodes.find((n) => n.id === focusId) || nodes[0];
const neighbours = nodes.filter((n) => n.id !== focusNode.id);
// Build an opaque "::" focus for a clicked neighbour, using
// the same contract the backend allowlist expects. Client-side we only have
// id/label/title - so only the name-keyed labels (Deal/Topic) are drillable
// by title. Company is domain-keyed (title is the display name, not the
// domain) and Person is email-keyed (we don't carry email) -> those nodes are
// shown but not clickable. Returns null when a node can't be re-centred.
function focusStringFor(n) {
if (!n.title) return null;
if (n.label === "Deal" || n.label === "Topic") {
return `${n.label}:name:${n.title}`;
}
return null;
}
const R = 26; // neighbour ring radius in viewBox units
const placed = neighbours.map((n, i) => {
const ang = (2 * Math.PI * i) / Math.max(1, neighbours.length) - Math.PI / 2;
return {
...n,
x: cx + R * Math.cos(ang),
y: cy + R * Math.sin(ang) * 0.82, // squash vertically to fit the card
};
});
return (
{/* edges: focus -> each neighbour (radial spokes) */}
{placed.map((n, i) => (
))}
{/* focus node (centre) */}
{shortLabel(focusNode.title, 16)}
{/* neighbour nodes */}
{placed.map((n, i) => {
const fs = focusStringFor(n);
const clickable = !!fs && !!onFocusNode;
return (
onFocusNode(fs) : undefined}>
{shortLabel(n.title, 12)}
);
})}
);
}
function MemoryCard({ data, syncLabel, graph, onFocusNode }) {
const loading = data === undefined || data === null;
const reachable = !loading && data.bolt === "reachable";
const nodes = (!loading && typeof data.nodes === "number") ? data.nodes : null;
const nodesLabel = loading
? "lade ..."
: (nodes === null ? "nicht erreichbar" : nodes.toLocaleString("de-DE"));
const meta = loading ? "lade ..." : (reachable ? "Live" : "offline");
return (
Gedächtnis · Graph
{meta}
Memgraph · Connies Gedaechtnis
{loading
? "Verbinde mit dem Wissensgraphen ..."
: reachable
? "Strukturiertes Wissen aus HubSpot, Gespraechen und Connies Notizen."
: "Der Wissensgraph ist gerade nicht erreichbar."}
Knoten {nodesLabel}
Sync {loading ? "lade ..." : (syncLabel || "offline")}
);
}
// ============================================================
// WETTER — local weather + next rain (frontend-only, Open-Meteo)
// ============================================================
// WMO weather-code -> short German description (local map, no library).
const WMO_DESC = {
0: "Klar",
1: "Ueberwiegend klar", 2: "Teils bewoelkt", 3: "Bedeckt",
45: "Nebel", 48: "Reifnebel",
51: "Leichter Niesel", 53: "Niesel", 55: "Starker Niesel",
56: "Gefrierender Niesel", 57: "Starker gefrierender Niesel",
61: "Leichter Regen", 63: "Regen", 65: "Starker Regen",
66: "Gefrierender Regen", 67: "Starker gefrierender Regen",
71: "Leichter Schneefall", 73: "Schneefall", 75: "Starker Schneefall",
77: "Schneegriesel",
80: "Leichte Regenschauer", 81: "Regenschauer", 82: "Starke Regenschauer",
85: "Leichte Schneeschauer", 86: "Starke Schneeschauer",
95: "Gewitter", 96: "Gewitter mit Hagel", 99: "Schweres Gewitter mit Hagel",
};
function wmoText(code) {
return WMO_DESC[code] != null ? WMO_DESC[code] : "Wetterlage unbekannt";
}
// Derive the next-rain sentence from the hourly arrays. "Regen jetzt" when the
// current slot is already wet; "Regen ab HH:MM" for the first future wet slot;
// otherwise "Heute kein Regen erwartet." A slot counts as wet when
// precipitation > 0 OR precipitation_probability >= 50.
function nextRainText(hourly) {
if (!hourly || !Array.isArray(hourly.time)) return "Heute kein Regen erwartet.";
const times = hourly.time;
const precip = hourly.precipitation || [];
const prob = hourly.precipitation_probability || [];
const now = Date.now();
const isWet = (i) => (Number(precip[i]) > 0) || (Number(prob[i]) >= 50);
// Find the slot index for the current hour (first slot whose time >= now-1h).
let nowIdx = -1;
for (let i = 0; i < times.length; i++) {
const t = new Date(times[i]).getTime();
if (!Number.isNaN(t) && t >= now - 3600 * 1000) { nowIdx = i; break; }
}
if (nowIdx === -1) nowIdx = 0;
if (isWet(nowIdx)) return "Regen jetzt.";
for (let i = nowIdx + 1; i < times.length; i++) {
if (isWet(i)) {
const hhmm = new Date(times[i]).toLocaleTimeString("de-DE",
{ hour: "2-digit", minute: "2-digit" });
return `Regen ab ${hhmm}.`;
}
}
return "Heute kein Regen erwartet.";
}
function WeatherCard() {
// state: idle | locating | loading | ok | denied | error
const [status, setStatus] = pState("idle");
const [weather, setWeather] = pState(null);
// The display label of the location the forecast is for (the saved Standort
// name/label when present, else null = "lokal" via geolocation).
const [placeLabel, setPlaceLabel] = pState(null);
// Fetch the open-meteo forecast for given coordinates + set state. Returns
// true on success. Coordinates are rounded to 2 decimals (~1km, SR-DV-8 /
// SR-ST-2b) before they leave the browser.
async function fetchForecast(lat, lon) {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${(+lat).toFixed(2)}`
+ `&longitude=${(+lon).toFixed(2)}`
+ `¤t=temperature_2m,weather_code,precipitation`
+ `&hourly=precipitation,precipitation_probability`
+ `&timezone=auto&forecast_days=1`;
const resp = await fetch(url);
if (!resp.ok) return false;
const data = await resp.json();
setWeather(data);
return true;
}
// The existing browser-geolocation path (now the FALLBACK, S2b): used when no
// per-user Standort is saved or the location lookup fails.
function loadFromGeolocation() {
if (!("geolocation" in navigator)) { setStatus("denied"); return; }
setPlaceLabel(null);
setStatus("locating");
navigator.geolocation.getCurrentPosition(
async (pos) => {
// SR-DV-8: round coordinates to 2 decimals (~1km) - enough for weather,
// far less precise. Coordinates live only in RAM here, never stored/logged.
const lat = pos.coords.latitude.toFixed(2);
const lon = pos.coords.longitude.toFixed(2);
setStatus("loading");
try {
if (await fetchForecast(lat, lon)) setStatus("ok");
else setStatus("error");
} catch (_) {
setStatus("error");
}
},
(_err) => { setStatus("denied"); }, // permission denied / unavailable: no IP fallback
{ timeout: 10000, maximumAge: 600000, enableHighAccuracy: false }
);
}
// S2b: prefer the per-user saved Standort (GET /web/location). If it has
// coordinates, use them; otherwise fall back to navigator.geolocation. The
// owner is forced server-side by connie-app - the browser never sends it.
async function load() {
const apiBase = (window.CONNIE_API_BASE || "http://127.0.0.1:8081").replace(/\/+$/, "");
const token = window.CONNIE_WEB_TOKEN || "";
if (!token) { loadFromGeolocation(); return; }
setStatus("loading");
try {
const resp = await fetch(`${apiBase}/web/location`, {
headers: { Authorization: `Bearer ${token}` },
});
if (resp.ok) {
const loc = await resp.json();
if (loc && loc.source === "manual"
&& typeof loc.lat === "number" && typeof loc.lon === "number") {
setPlaceLabel(loc.name || loc.label || null);
try {
if (await fetchForecast(loc.lat, loc.lon)) { setStatus("ok"); return; }
setStatus("error"); return;
} catch (_) { setStatus("error"); return; }
}
}
} catch (_) {
// fall through to geolocation
}
// No saved Standort (or lookup failed) -> existing geolocation path.
loadFromGeolocation();
}
pEffect(() => { load(); }, []);
const cur = (status === "ok" && weather && weather.current) ? weather.current : null;
const temp = cur && cur.temperature_2m != null
? `${Math.round(cur.temperature_2m)}°C` : "--";
const desc = cur ? wmoText(cur.weather_code) : "";
const rain = (status === "ok" && weather) ? nextRainText(weather.hourly) : "";
return (
Wetter · Heute
{status === "ok" ? (placeLabel || "lokal")
: status === "locating" ? "Standort ..."
: status === "loading" ? "lade ..."
: status === "denied" ? "kein Standort"
: status === "error" ? "Fehler" : "lade ..."}
{(status === "idle" || status === "locating" || status === "loading") && (
)}
{status === "ok" && (
)}
{(status === "denied" || status === "error") && (
{status === "denied"
? "Standort nicht freigegeben."
: "Wetterdienst gerade nicht erreichbar."}
Erneut versuchen
)}
{/* SR-DV-8 MUST: tell the user where the location goes. */}
Standort geht an Open-Meteo (Drittanbieter, Schweiz).
);
}
// ============================================================
// AKTIEN — per-user portfolio tile (GET /web/portfolio)
// ============================================================
// Header shows the EUR day-difference in EUR and %, green (teal) for +, red for
// -. Expandable to the per-stock list. Facts only - NO buy/sell badges in V1
// (Haftung, SR-SP-11). Disclaimer line at the bottom (from the API `disclaimer`).
// `data` is the tile shape from /web/portfolio: { available, per_stock, totals,
// disclaimer, currency } or null while loading.
function _fmtEur(n) {
if (n === null || n === undefined || typeof n !== "number" || !isFinite(n)) return "--";
// German format, 2 decimals, with a sign for non-zero day-diffs handled by callers.
return n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function _fmtPct(n) {
if (n === null || n === undefined || typeof n !== "number" || !isFinite(n)) return "--";
return n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function _signCls(n) {
if (typeof n !== "number" || !isFinite(n) || n === 0) return "flat";
return n > 0 ? "up" : "down";
}
// Tiny per-line trend sparkline for the Aktien tile (feature 3). Inline SVG
// polyline (build-free, no library) over the cleaned daily-close series the
// backend already finite-filtered + length-capped (SR-Y-6). Pure number list,
// never provider text - rendered as a path, never as markup. Colour: teal when
// the last point is above the first, red when below, neutral grey when equal.
// Edge cases: empty -> null (nothing rendered); 1 point -> a flat mid-line;
// all-equal -> a horizontal line (range guarded to 1).
function StockSparkline({ data }) {
const arr = (Array.isArray(data) ? data : []).filter(
(v) => typeof v === "number" && isFinite(v)
);
if (!arr.length) return null;
const w = 64, h = 22, pad = 2;
if (arr.length === 1) {
return (
);
}
const max = Math.max(...arr), min = Math.min(...arr);
const range = (max - min) || 1;
const up = arr[arr.length - 1] >= arr[0];
const color = arr[arr.length - 1] === arr[0]
? "var(--fg-2)"
: (up ? "var(--teal)" : "var(--danger)");
const pts = arr
.map((v, i) =>
`${((i / (arr.length - 1)) * w).toFixed(1)},`
+ `${(h - ((v - min) / range) * (h - 2 * pad) - pad).toFixed(1)}`
)
.join(" ");
return (
);
}
function StockPortfolioCard({ data }) {
const [open, setOpen] = pState(false);
const loading = data === null || data === undefined;
const available = !loading && data.available === true;
const totals = (!loading && data.totals) || {};
const list = (!loading && Array.isArray(data.per_stock)) ? data.per_stock : [];
const disclaimer = (!loading && typeof data.disclaimer === "string") ? data.disclaimer : "";
const diffEur = typeof totals.diff_eur === "number" ? totals.diff_eur : null;
const diffPct = typeof totals.diff_pct === "number" ? totals.diff_pct : null;
const sign = _signCls(diffEur);
const arrow = sign === "down" ? "trending-down" : "trending-up";
const signChar = (typeof diffEur === "number" && diffEur > 0) ? "+" : "";
const staleCount = typeof totals.stale_count === "number" ? totals.stale_count : 0;
const valueEur = typeof totals.value_eur === "number" ? totals.value_eur : null;
// total_partial (feature 2): the shown sum omits N stale lines -> label it so
// the figure is not read as the full portfolio value.
const totalPartial = totals.total_partial === true;
return (
Aktien · Heute
{loading ? "lade ..."
: !available ? "leer"
: `${list.length} ${list.length === 1 ? "Position" : "Positionen"}`}
{loading &&
}
{!loading && !available && (
)}
{!loading && available && (
setOpen((o) => !o)}
aria-expanded={open}
title={open ? "Einzelaktien ausblenden" : "Einzelaktien anzeigen"}
>
{signChar}{_fmtEur(diffEur)} €
({signChar}{_fmtPct(diffPct)} %)
{/* Feature 2: prominent portfolio total. When the sum omits stale
lines it is labelled "Teil-Summe (ohne N veraltete)" so it is not
mistaken for the full portfolio value. */}
{totalPartial
? `Teil-Summe (ohne ${staleCount} veraltete)`
: "Portfolio-Wert"}
{_fmtEur(valueEur)} €
{/* A stale note only when there are stale lines but no partial label
already says so (e.g. all positions stale -> value 0). */}
{staleCount > 0 && !totalPartial && (
{staleCount} Kurs(e) evtl. veraltet
)}
{open && (
{list.map((s, i) => {
const lSign = _signCls(s.diff_eur);
const lSignChar = (typeof s.diff_eur === "number" && s.diff_eur > 0) ? "+" : "";
const lValue = typeof s.value_eur === "number" ? s.value_eur : null;
return (
{/* Name (longName) shown ALWAYS - confusion protection
against a wrong-but-valid symbol (SR-Y-4). */}
{s.name || s.wkn}
{s.wkn}{typeof s.qty === "number" ? ` · ${_fmtPct(s.qty)} Stk` : ""}
{s.exchange ? ` · ${s.exchange}` : ""}
{s.currency ? ` · ${s.currency}` : ""}
{/* Feature 3: per-line trend sparkline (hidden for stale
lines - a missing/old series would mislead). */}
{!s.stale &&
}
{s.stale ? (
Kurs n/a
) : (
{/* Feature 1: EUR position value (qty x price x fx). */}
{_fmtEur(lValue)} €
{lSignChar}{_fmtEur(s.diff_eur)} € ({lSignChar}{_fmtPct(s.diff_pct)} %)
)}
);
})}
)}
)}
{/* SR-SP-11: facts only, no advice. Disclaimer always visible. */}
{disclaimer &&
{disclaimer}
}
);
}
// ============================================================
// ARBEITSGEDÄCHTNIS — recent cross-channel cues (GET /web/recent)
// ============================================================
// Channel -> short label + an icon name (reuses the inline ICON_PATHS set;
// falls back to "bot" for unknown channels). No new CDN, JSX text only (SR-DV-9).
const WM_CHANNEL = {
telegram: { label: "Telegram", icon: "mail" },
whatsapp: { label: "WhatsApp", icon: "mail" },
web: { label: "Dashboard", icon: "bot" },
web: { label: "Web", icon: "bot" },
};
// Relative time from an ISO/epoch ts, e.g. "vor 4 Min" / "vor 2 Std" / "gerade".
function relTime(ts) {
if (!ts) return "";
let then;
if (typeof ts === "number") then = ts * 1000;
else then = new Date(String(ts).endsWith("Z") ? ts : ts).getTime();
if (!then || Number.isNaN(then)) return "";
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
if (secs < 60) return "gerade";
const mins = Math.round(secs / 60);
if (mins < 60) return `vor ${mins} Min`;
const hrs = Math.round(mins / 60);
if (hrs < 48) return `vor ${hrs} Std`;
const days = Math.round(hrs / 24);
return `vor ${days} Tg`;
}
// The hot working-memory timeline. `items` come from /web/recent already shaped
// + peer-MASKED by the backend ({ts, channel, peer_display, summary}); we only
// render. peer_display is the masked form (e.g. "+49 170 ***") - no full PII
// ever reaches here (SR-DV-4/§5.8). All fields are JSX text nodes (no innerHTML).
function WorkingMemoryCard({ items }) {
const loading = items === null || items === undefined;
const list = Array.isArray(items) ? items : [];
const meta = loading ? "lade ..." : `${list.length} ${list.length === 1 ? "Eintrag" : "Einträge"}`;
return (
Arbeitsgedächtnis · Live
{meta}
{loading &&
}
{!loading && list.length === 0 && (
)}
{!loading && list.map((it, i) => {
const ch = WM_CHANNEL[it.channel] || { label: it.channel || "", icon: "bot" };
return (
{it.summary}
{it.peer_display || "***"}
{ch.label && {ch.label} }
{relTime(it.ts)}
);
})}
);
}
// ============================================================
// AKTIVITÄT — live tool feed
// ============================================================
function ActivityCard({ items }) {
return (
Aktivität · Live
{items.length} Schritte
{items.length === 0 && (
Keine aktiven Aufgaben.
)}
{items.map((a, i) => (
{a.status === "running"
?
: }
{a.text}
{a.sub &&
{a.sub}
}
{a.time}
))}
);
}
// ============================================================
// TRANSKRIPT — chat log
// ============================================================
function TranscriptCard({ messages }) {
const scrollRef = pRef(null);
pEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
return (
Transkript
{messages.length} Nachrichten
{messages.map((m, i) => (
{m.who === "user" ? "Bernd" : "Connie"}
{m.text}
))}
);
}
// ============================================================
// WORKFLOWS — approval queue + start + recent runs (GET /web/workflows|runs)
// ============================================================
// One card, three stacked areas:
// 1. Freigabe-Warteschlange - runs awaiting approval (status==awaiting_approval).
// The most important part: Bernd approves/rejects Connie's pending actions.
// Each row carries Freigeben (decision approve) / Ablehnen (reject); the
// masked preview (approval.preview, redacted by the backend) can be loaded
// on demand and shown collapsibly.
// 2. Workflows starten - the catalog from /web/workflows. Required inputs_spec
// fields render as a tiny inline form; on success a short "Gestartet".
// 3. Letzte Laeufe - a compact list of non-awaiting runs with a status badge.
// All text is JSX text nodes (no innerHTML, SR-DV-9). The workflow names + the
// decision values come from the backend contract, never free-typed (SR side).
// Map a run status to {label, mod} - the modifier drives the badge colour in
// styles.css (.wf-badge--). Unknown -> neutral grey.
const WF_STATUS = {
running: { label: "läuft", mod: "running" },
awaiting_approval: { label: "Freigabe", mod: "awaiting" },
done: { label: "fertig", mod: "done" },
failed: { label: "Fehler", mod: "failed" },
partial: { label: "teilweise", mod: "partial" },
cancelled: { label: "abgebrochen", mod: "cancelled" },
};
function wfStatus(s) {
return WF_STATUS[s] || { label: s || "unbekannt", mod: "neutral" };
}
// Relative "seit" label for an ISO/epoch timestamp (reuses relTime's German
// wording but phrased as a duration, e.g. "seit 4 Min"). Falls back to "".
function wfSince(ts) {
const rel = relTime(ts);
if (!rel) return "";
if (rel === "gerade") return "gerade eben";
return rel.replace(/^vor /, "seit ");
}
// The approval preview can be HTML (e.g. a branded mail draft: ...
).
// Render it as READABLE TEXT, not raw markup, and WITHOUT innerHTML (the draft
// is LLM-produced and could carry an injected