Organisationsvorlagen: ein ganzes Haus als importierbare Datei (#691)

- Core/Organisation/OrgTemplate: format, export, parse, structural checks.
  Export empties every credential (nested, name variants) and names it;
  a template that carries one is refused.
- OrgAdministration: ExportTemplate, PreviewTemplate (problems, warnings,
  every text in full), ImportTemplateAsync - only with confirmation, units
  before agents, own workspace per unit, doctrines, model and budget factor
  overridable, templateOrigin on every unit and agent.
- Endpoints GET /organisation/template, POST .../preview, POST .../import;
  templates card in the organisation view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 12:37:18 +02:00
co-authored by Claude Opus 5.5
parent 11465f3d74
commit 706b5cfd9e
14 changed files with 880 additions and 3 deletions
@@ -2637,6 +2637,19 @@ button.org-einheit .org-name {
gap: 4px;
}
/* #691: a template before it comes in — every agent with its full texts. */
.org-vorlage-agent {
border-left: 3px solid var(--accent);
padding: 6px 12px;
margin-bottom: 10px;
}
.org-vorlage-agent pre {
max-height: 240px;
overflow: auto;
white-space: pre-wrap;
}
/* #702: a vote beside the call — every ballot with its reasoning and evidence. */
.staging-stimmen {
list-style: none;
@@ -152,6 +152,23 @@
"org.voteTallyText": "{yes} ja · {no} nein · {missing} fehlen",
"org.voteOutcome": "Ergebnis",
"org.voteCost": "Kosten der Abstimmenden",
"org.templates": "Organisationsvorlagen",
"org.templatesHint": "Ein ganzes Haus als Datei: Einheiten, Regeln, Doktrinen und Agenten mit ihren Texten, Werkzeugen, Modellen und Budgets. Exportiert ohne Zugangsdaten — sie werden benannt, nie mitgegeben. Importiert nur in IDs und Namen, die diese Instanz noch nicht hat, und erst, nachdem du gelesen hast, was hereinkommt.",
"org.templateNamePlaceholder": "Name der Vorlage",
"org.templateExport": "Exportieren",
"org.templateExportedCredentials": "Exportiert. {n} Zugangsdaten wurden weggelassen und sind in der Datei benannt.",
"org.templateFile": "Vorlage importieren",
"org.templateModel": "Modell für alle Agenten (optional)",
"org.templateModelPlaceholder": "Modelle der Vorlage behalten",
"org.templateBudgetFactor": "Budgetfaktor",
"org.templateProblems": "Die Vorlage lässt sich nicht importieren:",
"org.templateUnits": "{n} Einheit(en)",
"org.templateAgents": "{n} Agent(en)",
"org.templateDay": "Tag",
"org.templateCredentials": "Danach einzutragende Zugangsdaten",
"org.templateConfirm": "Ich habe die Identity- und Soul-Texte gelesen. Sie werden zu den Prompts meiner Agenten.",
"org.templateImport": "Importieren",
"org.templateUnreadable": "Die Datei ist keine Vorlage: {error}",
"org.ceilingInherited": "Von oben begrenzt auf: {tools}",
"org.settings": "Sammelwerte",
"org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.",
@@ -152,6 +152,23 @@
"org.voteTallyText": "{yes} yes · {no} no · {missing} missing",
"org.voteOutcome": "Outcome",
"org.voteCost": "Cost of the voters",
"org.templates": "Organisation templates",
"org.templatesHint": "A whole house as a file: units, rules, doctrines and agents with their texts, tools, models and budgets. Exported without credentials — they are named, never carried. Imported only into ids and names this instance does not have yet, after you have read what comes in.",
"org.templateNamePlaceholder": "Template name",
"org.templateExport": "Export",
"org.templateExportedCredentials": "Exported. {n} credential(s) were left out and are named in the file.",
"org.templateFile": "Import a template",
"org.templateModel": "Model for all agents (optional)",
"org.templateModelPlaceholder": "keep the template's models",
"org.templateBudgetFactor": "Budget factor",
"org.templateProblems": "The template cannot be imported:",
"org.templateUnits": "{n} unit(s)",
"org.templateAgents": "{n} agent(s)",
"org.templateDay": "day",
"org.templateCredentials": "Credentials to fill in afterwards",
"org.templateConfirm": "I have read the Identity and Soul texts. They become my agents' prompts.",
"org.templateImport": "Import",
"org.templateUnreadable": "The file is no template: {error}",
"org.ceilingInherited": "Limited from above to: {tools}",
"org.settings": "Unit-wide values",
"org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.",
@@ -307,7 +307,10 @@ function renderDetail() {
], { klasse: "org-grund" });
if (!item) {
detailBox.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("org.selectHint") }));
detailBox.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: t("org.selectHint") }),
reasonCard,
templatesCard());
return;
}
@@ -317,6 +320,119 @@ function renderDetail() {
detailBox.replaceChildren(reasonCard, ...panels);
}
/**
* #691: The organisation as a template file. Export never carries credentials; import shows
* everything first — units, agents, models, budgets and the full Identity and Soul texts, which
* become prompts — and only runs after an explicit confirmation.
*/
function templatesCard() {
const name = erstelleElement("input", { type: "text", placeholder: t("org.templateNamePlaceholder") });
const file = erstelleElement("input", { type: "file", accept: ".json,application/json" });
const model = erstelleElement("input", { type: "text", placeholder: t("org.templateModelPlaceholder") });
const factor = erstelleElement("input", { type: "number", min: "0.01", step: "0.1", value: "1" });
const result = erstelleElement("div", { klasse: "org-vorlage" });
let template = null;
let source = "";
const body = () => ({
template,
model: model.value.trim() || null,
budgetFactor: Number(factor.value) > 0 && Number(factor.value) !== 1 ? Number(factor.value) : null,
source,
reason: reason()
});
const exportTemplate = async () => {
try {
const tpl = await api(`/organisation/template?name=${encodeURIComponent(name.value.trim())}`);
const blob = new Blob([JSON.stringify(tpl, null, 2)], { type: "application/json" });
const link = erstelleElement("a", { href: URL.createObjectURL(blob) });
link.download = `${(name.value.trim() || "organisation").replace(/[^\w-]+/g, "_")}.org-template.json`;
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
if (tpl.credentials?.length) zeigeMeldung(t("org.templateExportedCredentials", { n: tpl.credentials.length }), "erfolg", 8000);
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
}
};
const preview = async () => {
if (!template) return;
let p;
try {
p = await api("/organisation/template/preview", { method: "POST", body: body() });
} catch (err) {
result.replaceChildren(erstelleElement("p", { klasse: "fehler", text: err.message }));
return;
}
const confirm = erstelleElement("input", { type: "checkbox" });
const run = erstelleElement("button", {
type: "button", klasse: "primaer klein", text: t("org.templateImport"), disabled: true,
onclick: async () => {
const ok = await send("/organisation/template/import", "POST", { ...body(), confirm: confirm.checked });
if (ok) { template = null; result.replaceChildren(); file.value = ""; }
}
});
confirm.addEventListener("change", () => { run.disabled = !confirm.checked || !p.canImport; });
result.replaceChildren(
erstelleElement("h4", { text: `${p.name} · v${p.version} · ${p.fingerprint}` }),
p.problems.length
? erstelleElement("div", { klasse: "org-probleme" },
erstelleElement("strong", { text: t("org.templateProblems") }),
erstelleElement("ul", {}, ...p.problems.map((x) => erstelleElement("li", { text: x }))))
: "",
...p.warnings.map((w) => erstelleElement("p", { klasse: "hinweis-box warnung text-klein", text: w })),
erstelleElement("h4", { text: t("org.templateUnits", { n: p.units.length }) }),
erstelleElement("ul", { klasse: "org-liste" }, ...p.units.map((u) => erstelleElement("li", { klasse: "text-klein",
text: `${u.path}${u.leaderAgentId ? ` · ${t("org.leadShort")}: ${u.leaderAgentId}` : ""}` }))),
erstelleElement("h4", { text: t("org.templateAgents", { n: p.agents.length }) }),
...p.agents.map((a) => erstelleElement("div", { klasse: "org-vorlage-agent" },
erstelleElement("strong", { text: `${a.displayName} (${a.agentId})` }),
erstelleElement("div", { klasse: "gedaempft text-klein",
text: `${a.unitId ?? t("org.topLevel")} · ${a.model} · $${a.dailyCostUsd}/${t("org.templateDay")} · ${a.tools.join(", ") || "—"}` }),
erstelleElement("div", { klasse: "text-klein", text: "Identity" }),
erstelleElement("pre", { klasse: "code-block", text: a.identity || "—" }),
erstelleElement("div", { klasse: "text-klein", text: "Soul" }),
erstelleElement("pre", { klasse: "code-block", text: a.soul || "—" }))),
p.credentials.length
? erstelleElement("div", {},
erstelleElement("h4", { text: t("org.templateCredentials") }),
erstelleElement("ul", { klasse: "org-liste" }, ...p.credentials.map((c) => erstelleElement("li", { klasse: "text-klein",
text: `${c.agentId} · ${c.tool} · ${c.field}` }))))
: "",
erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, confirm, erstelleElement("span", { text: t("org.templateConfirm") })),
run);
};
file.addEventListener("change", async () => {
const f = file.files?.[0];
if (!f) return;
try {
template = JSON.parse(await f.text());
source = f.name;
await preview();
} catch (err) {
template = null;
result.replaceChildren(erstelleElement("p", { klasse: "fehler", text: t("org.templateUnreadable", { error: err.message }) }));
}
});
model.addEventListener("change", preview);
factor.addEventListener("change", preview);
return erstelleKarte(t("org.templates"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.templatesHint") }),
erstelleElement("div", { klasse: "gruppe-horizontal" }, name,
erstelleElement("button", { type: "button", klasse: "klein", text: t("org.templateExport"), onclick: exportTemplate })),
erstelleElement("div", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.templateFile") }), file,
erstelleElement("label", { text: t("org.templateModel") }), model,
erstelleElement("label", { text: t("org.templateBudgetFactor") }), factor),
result
]);
}
function unitPanel(unit) {
const cards = [];