ltcai 1.6.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +40 -19
  2. package/docs/CHANGELOG.md +107 -0
  3. package/docs/EDITION_STRATEGY.md +14 -4
  4. package/docs/ENTERPRISE.md +11 -3
  5. package/docs/MULTI_AGENT_RUNTIME.md +410 -0
  6. package/docs/PLUGIN_SDK.md +651 -0
  7. package/docs/REALTIME_COLLABORATION.md +410 -0
  8. package/docs/V2_ARCHITECTURE.md +528 -0
  9. package/docs/WORKFLOW_DESIGNER.md +475 -0
  10. package/latticeai/__init__.py +1 -1
  11. package/latticeai/api/agents.py +98 -0
  12. package/latticeai/api/plugins.py +115 -0
  13. package/latticeai/api/realtime.py +91 -0
  14. package/latticeai/api/workflow_designer.py +207 -0
  15. package/latticeai/core/multi_agent.py +270 -0
  16. package/latticeai/core/plugins.py +400 -0
  17. package/latticeai/core/realtime.py +190 -0
  18. package/latticeai/core/workflow_engine.py +329 -0
  19. package/latticeai/core/workspace_os.py +165 -2
  20. package/latticeai/server_app.py +76 -2
  21. package/latticeai/services/platform_runtime.py +200 -0
  22. package/package.json +17 -2
  23. package/plugins/README.md +35 -0
  24. package/plugins/git-insights/plugin.json +15 -0
  25. package/plugins/hello-world/plugin.json +16 -0
  26. package/plugins/hello-world/skills/hello_skill/SKILL.md +15 -0
  27. package/static/activity.html +70 -0
  28. package/static/admin.html +62 -0
  29. package/static/agents.html +92 -0
  30. package/static/graph.html +7 -1
  31. package/static/lattice-reference.css +184 -0
  32. package/static/platform.css +75 -0
  33. package/static/plugins.html +82 -0
  34. package/static/scripts/admin.js +121 -1
  35. package/static/scripts/graph.js +296 -14
  36. package/static/scripts/platform.js +64 -0
  37. package/static/scripts/workspace.js +107 -10
  38. package/static/workflows.html +121 -0
  39. package/static/workspace.css +73 -0
  40. package/static/workspace.html +18 -2
@@ -0,0 +1,92 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Multi-Agent Runtime — Lattice AI</title>
7
+ <link rel="stylesheet" href="/static/platform.css" />
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <h1>Multi-Agent Runtime</h1>
12
+ <p class="sub">Planner · Executor · Reviewer · Researcher · Release — with handoff, retry, and an observable timeline.</p>
13
+
14
+ <div class="section">
15
+ <label>Goal</label>
16
+ <textarea id="goal" placeholder="e.g. Draft a release checklist for v2.0.0">Summarize and verify the latest workspace activity</textarea>
17
+ <label>Roles (pipeline)</label>
18
+ <div id="roleChips" class="row"></div>
19
+ <div class="row" style="margin-top:14px"><button id="runBtn">Run agents</button></div>
20
+ </div>
21
+
22
+ <div class="section">
23
+ <h3>Run result</h3>
24
+ <div id="result"><div class="empty">No run yet.</div></div>
25
+ </div>
26
+
27
+ <div class="section">
28
+ <h3>Recent agent runs</h3>
29
+ <div id="runs"><div class="empty">Loading…</div></div>
30
+ </div>
31
+ </main>
32
+
33
+ <script type="module">
34
+ import { mountHeader, api, escapeHtml, badge, toast } from "/static/scripts/platform.js";
35
+ mountHeader("/agents");
36
+
37
+ const selected = new Set(["planner", "executor", "reviewer"]);
38
+ async function loadRoles() {
39
+ const data = await api("/agents/api/roles");
40
+ document.getElementById("roleChips").innerHTML = data.roles.map((r) =>
41
+ `<label class="badge" style="cursor:pointer"><input type="checkbox" value="${r.role}" ${selected.has(r.role)?"checked":""} style="width:auto;margin-right:6px">${escapeHtml(r.role)}</label>`
42
+ ).join(" ");
43
+ document.getElementById("roleChips").addEventListener("change", (e) => {
44
+ if (e.target.checked) selected.add(e.target.value); else selected.delete(e.target.value);
45
+ });
46
+ }
47
+
48
+ function renderTimeline(timeline) {
49
+ return (timeline || []).map((t) => {
50
+ const label = t.event === "handoff" ? `↪ handoff ${escapeHtml(t.from)} → ${escapeHtml(t.to)}`
51
+ : t.event === "role" ? `● ${escapeHtml(t.role)} ${badge(t.status)}`
52
+ : `· ${escapeHtml(t.event)}`;
53
+ return `<div class="timeline-item">${label}<div class="t-meta">${escapeHtml(t.note||t.timestamp||"")}</div></div>`;
54
+ }).join("");
55
+ }
56
+
57
+ document.getElementById("runBtn").addEventListener("click", async () => {
58
+ const btn = document.getElementById("runBtn");
59
+ btn.disabled = true;
60
+ try {
61
+ const res = await api("/agents/api/run", { method: "POST", body: JSON.stringify({
62
+ goal: document.getElementById("goal").value, roles: [...selected], inputs: {}
63
+ }) });
64
+ const r = res.result;
65
+ document.getElementById("result").innerHTML = `
66
+ <div class="card">
67
+ <div class="row"><h3>${escapeHtml(r.output)}</h3><div class="spacer"></div>${badge(r.status)}</div>
68
+ <div class="meta">retries: ${r.retries} · roles: ${(r.roles_run||[]).join(" → ")}</div>
69
+ <div class="section">${renderTimeline(r.timeline)}</div>
70
+ </div>`;
71
+ toast(`Agent run: ${r.status}`);
72
+ await loadRuns();
73
+ } catch (err) { toast(err.message); } finally { btn.disabled = false; }
74
+ });
75
+
76
+ async function loadRuns() {
77
+ const data = await api("/agents/api/runs");
78
+ const runs = data.runs || [];
79
+ const box = document.getElementById("runs");
80
+ if (!runs.length) { box.innerHTML = `<div class="empty">No agent runs yet.</div>`; return; }
81
+ box.innerHTML = runs.slice(0, 20).map((r) => `
82
+ <div class="card" style="margin-bottom:10px">
83
+ <div class="row"><h3>${escapeHtml((r.input||"").slice(0,80))}</h3><div class="spacer"></div>${badge(r.status)}</div>
84
+ <div class="meta">${escapeHtml(r.agent_id)} · ${escapeHtml(r.created_at)} · ${(r.timeline||[]).length} timeline events</div>
85
+ </div>`).join("");
86
+ }
87
+
88
+ loadRoles().catch((e) => toast(e.message));
89
+ loadRuns().catch(() => {});
90
+ </script>
91
+ </body>
92
+ </html>
package/static/graph.html CHANGED
@@ -51,7 +51,12 @@
51
51
  </section>
52
52
 
53
53
  <div class="toolbar">
54
- <button class="tb-btn" id="refresh-btn">↺ Refresh</button>
54
+ <button class="tb-btn" id="refresh-btn"><i class="ti ti-refresh"></i> Refresh</button>
55
+ <button class="tb-btn" id="fit-btn" title="Fit graph"><i class="ti ti-arrows-maximize"></i> Fit</button>
56
+ <button class="tb-btn" id="expand-btn" title="Expand selected node"><i class="ti ti-circle-plus"></i> Expand</button>
57
+ <button class="tb-btn" id="collapse-btn" title="Collapse selected neighbors"><i class="ti ti-circle-minus"></i> Collapse</button>
58
+ <button class="tb-btn" id="focus-btn" title="Focus selected subgraph"><i class="ti ti-focus-2"></i> Focus</button>
59
+ <button class="tb-btn" id="path-btn" title="Shortest path from saved start"><i class="ti ti-route"></i> Path</button>
55
60
  <div class="lang-picker" id="graph-lang-picker">
56
61
  <button class="tb-btn" id="graph-lang-btn" type="button" onclick="toggleLangMenu('graph-lang-picker')">Language</button>
57
62
  <div class="lang-picker-menu" id="graph-lang-picker-menu">
@@ -60,6 +65,7 @@
60
65
  </div>
61
66
  </div>
62
67
  </div>
68
+ <div id="graph-focus-chip" class="focus-chip" hidden></div>
63
69
  </main>
64
70
 
65
71
  <aside>
@@ -1555,6 +1555,91 @@
1555
1555
  line-height: 1.5;
1556
1556
  }
1557
1557
 
1558
+ .lattice-ref-admin .enterprise-grid {
1559
+ display: grid;
1560
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
1561
+ gap: 10px;
1562
+ }
1563
+
1564
+ .lattice-ref-admin .enterprise-cap-card {
1565
+ display: flex;
1566
+ align-items: center;
1567
+ gap: 10px;
1568
+ min-width: 0;
1569
+ border: 1px solid rgba(111,66,232,0.12);
1570
+ border-radius: 8px;
1571
+ background: rgba(255,255,255,0.70);
1572
+ padding: 11px 12px;
1573
+ }
1574
+
1575
+ .lattice-ref-admin .enterprise-cap-card i {
1576
+ color: #7a74a0;
1577
+ font-size: 18px;
1578
+ }
1579
+
1580
+ .lattice-ref-admin .enterprise-cap-card.on i {
1581
+ color: #0d8f72;
1582
+ }
1583
+
1584
+ .lattice-ref-admin .enterprise-cap-card span {
1585
+ flex: 1;
1586
+ min-width: 0;
1587
+ color: #14162c;
1588
+ font-size: 13px;
1589
+ font-weight: 800;
1590
+ overflow: hidden;
1591
+ text-overflow: ellipsis;
1592
+ white-space: nowrap;
1593
+ text-transform: capitalize;
1594
+ }
1595
+
1596
+ .lattice-ref-admin .enterprise-cap-card strong {
1597
+ color: #4a4668;
1598
+ font-size: 11px;
1599
+ }
1600
+
1601
+ .lattice-ref-admin .enterprise-kv {
1602
+ display: grid;
1603
+ gap: 8px;
1604
+ }
1605
+
1606
+ .lattice-ref-admin .enterprise-kv div {
1607
+ display: grid;
1608
+ grid-template-columns: 150px minmax(0, 1fr);
1609
+ gap: 10px;
1610
+ align-items: start;
1611
+ border: 1px solid rgba(111,66,232,0.10);
1612
+ border-radius: 8px;
1613
+ background: rgba(255,255,255,0.62);
1614
+ padding: 9px 10px;
1615
+ }
1616
+
1617
+ .lattice-ref-admin .enterprise-kv span {
1618
+ color: #4a4668;
1619
+ font-size: 12px;
1620
+ font-weight: 800;
1621
+ }
1622
+
1623
+ .lattice-ref-admin .enterprise-kv strong {
1624
+ color: #14162c;
1625
+ font-size: 12px;
1626
+ line-height: 1.45;
1627
+ overflow-wrap: anywhere;
1628
+ }
1629
+
1630
+ .lattice-ref-admin .enterprise-json {
1631
+ max-height: 280px;
1632
+ overflow: auto;
1633
+ margin: 12px 0 0;
1634
+ border: 1px solid rgba(111,66,232,0.10);
1635
+ border-radius: 8px;
1636
+ background: rgba(20,22,44,0.05);
1637
+ color: #14162c;
1638
+ padding: 12px;
1639
+ font-size: 12px;
1640
+ white-space: pre-wrap;
1641
+ }
1642
+
1558
1643
  @media (max-width: 980px) {
1559
1644
  .lattice-ref-chat .reference-card-grid,
1560
1645
  .reference-lists {
@@ -2784,9 +2869,11 @@ body.lattice-ref-graph {
2784
2869
  top: 16px;
2785
2870
  right: 16px;
2786
2871
  display: flex;
2872
+ flex-wrap: wrap;
2787
2873
  gap: 8px;
2788
2874
  padding: 8px;
2789
2875
  border-radius: 10px;
2876
+ max-width: min(760px, calc(100% - 32px));
2790
2877
  }
2791
2878
 
2792
2879
  .tb-btn {
@@ -3301,6 +3388,103 @@ body.lattice-ref-graph {
3301
3388
 
3302
3389
  .jump-btn:hover { filter: brightness(1.04); }
3303
3390
 
3391
+ .jump-btn.secondary {
3392
+ border: 1px solid rgba(111,66,232,0.20);
3393
+ color: var(--text);
3394
+ background: rgba(255,255,255,0.82);
3395
+ box-shadow: none;
3396
+ cursor: pointer;
3397
+ font: inherit;
3398
+ font-size: 12px;
3399
+ font-weight: 700;
3400
+ }
3401
+
3402
+ .detail-actions {
3403
+ display: flex;
3404
+ flex-wrap: wrap;
3405
+ gap: 8px;
3406
+ margin-bottom: 14px;
3407
+ }
3408
+
3409
+ .related-node-list {
3410
+ display: grid;
3411
+ gap: 7px;
3412
+ margin-bottom: 14px;
3413
+ }
3414
+
3415
+ .related-node-btn {
3416
+ width: 100%;
3417
+ min-width: 0;
3418
+ display: grid;
3419
+ grid-template-columns: 18px minmax(0, 1fr) auto;
3420
+ align-items: center;
3421
+ gap: 8px;
3422
+ border: 1px solid rgba(111,66,232,0.13);
3423
+ border-radius: 8px;
3424
+ background: rgba(255,255,255,0.76);
3425
+ color: var(--text);
3426
+ padding: 8px 10px;
3427
+ text-align: left;
3428
+ cursor: pointer;
3429
+ }
3430
+
3431
+ .related-node-btn:hover {
3432
+ border-color: rgba(111,66,232,0.34);
3433
+ background: rgba(111,66,232,0.07);
3434
+ }
3435
+
3436
+ .related-node-btn strong {
3437
+ min-width: 0;
3438
+ overflow: hidden;
3439
+ text-overflow: ellipsis;
3440
+ white-space: nowrap;
3441
+ font-size: 12px;
3442
+ }
3443
+
3444
+ .related-node-btn em {
3445
+ color: var(--faint);
3446
+ font-size: 11px;
3447
+ font-style: normal;
3448
+ white-space: nowrap;
3449
+ }
3450
+
3451
+ .focus-chip {
3452
+ position: absolute;
3453
+ z-index: 21;
3454
+ left: 16px;
3455
+ bottom: 16px;
3456
+ max-width: min(560px, calc(100% - 32px));
3457
+ display: flex;
3458
+ flex-wrap: wrap;
3459
+ gap: 8px;
3460
+ padding: 8px;
3461
+ border: 1px solid var(--line);
3462
+ border-radius: 10px;
3463
+ background: rgba(255,255,255,0.90);
3464
+ box-shadow: var(--shadow);
3465
+ backdrop-filter: blur(18px);
3466
+ }
3467
+
3468
+ .focus-chip span {
3469
+ display: inline-flex;
3470
+ align-items: center;
3471
+ gap: 6px;
3472
+ border-radius: 999px;
3473
+ background: rgba(111,66,232,0.08);
3474
+ color: var(--text);
3475
+ padding: 5px 10px;
3476
+ font-size: 11px;
3477
+ font-weight: 800;
3478
+ max-width: 260px;
3479
+ overflow: hidden;
3480
+ text-overflow: ellipsis;
3481
+ white-space: nowrap;
3482
+ }
3483
+
3484
+ .search-shell.search-open .search-results {
3485
+ display: block;
3486
+ }
3487
+
3304
3488
  .empty-hint {
3305
3489
  margin: 0;
3306
3490
  color: var(--muted);
@@ -0,0 +1,75 @@
1
+ /* Lattice AI v2.0 — shared styling for the Agentic Workspace Platform pages
2
+ (Plugin SDK, Workflow Designer, Multi-Agent Runtime, Realtime Activity). */
3
+ :root {
4
+ --bg: #0f1115;
5
+ --panel: #16191f;
6
+ --panel-2: #1c2027;
7
+ --border: rgba(255, 255, 255, 0.08);
8
+ --text: #e7ecf3;
9
+ --muted: #94a3b8;
10
+ --accent: #378ADD;
11
+ --accent-2: #5ea7ec;
12
+ --ok: #34d399;
13
+ --warn: #fbbf24;
14
+ --err: #f87171;
15
+ }
16
+ * { box-sizing: border-box; }
17
+ body {
18
+ margin: 0;
19
+ background: var(--bg);
20
+ color: var(--text);
21
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
22
+ line-height: 1.5;
23
+ }
24
+ a { color: var(--accent-2); text-decoration: none; }
25
+ header.app {
26
+ display: flex; align-items: center; gap: 18px;
27
+ padding: 14px 24px; border-bottom: 1px solid var(--border);
28
+ background: rgba(22, 25, 31, 0.8); position: sticky; top: 0; backdrop-filter: blur(8px); z-index: 5;
29
+ }
30
+ header.app .brand { font-weight: 700; font-size: 16px; color: #fff; letter-spacing: .3px; }
31
+ header.app .brand small { color: var(--accent); font-weight: 600; margin-left: 6px; }
32
+ header.app nav { display: flex; gap: 14px; flex-wrap: wrap; }
33
+ header.app nav a { color: var(--muted); font-size: 13px; padding: 4px 6px; border-radius: 6px; }
34
+ header.app nav a:hover, header.app nav a.active { color: #fff; background: var(--panel-2); }
35
+ main { max-width: 1080px; margin: 0 auto; padding: 28px 24px 80px; }
36
+ h1 { font-size: 22px; margin: 0 0 4px; }
37
+ .sub { color: var(--muted); font-size: 13px; margin: 0 0 24px; }
38
+ .grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
39
+ .card {
40
+ background: var(--panel); border: 1px solid var(--border); border-radius: 14px;
41
+ padding: 16px 18px;
42
+ }
43
+ .card h3 { margin: 0 0 6px; font-size: 15px; }
44
+ .card .meta { color: var(--muted); font-size: 12px; }
45
+ .row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
46
+ .spacer { flex: 1; }
47
+ .badge {
48
+ display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px;
49
+ background: var(--panel-2); color: var(--muted); border: 1px solid var(--border);
50
+ }
51
+ .badge.ok { color: var(--ok); border-color: rgba(52,211,153,.4); }
52
+ .badge.warn { color: var(--warn); border-color: rgba(251,191,36,.4); }
53
+ .badge.err { color: var(--err); border-color: rgba(248,113,113,.4); }
54
+ button, .btn {
55
+ background: var(--accent); color: #fff; border: none; border-radius: 8px;
56
+ padding: 7px 14px; font-size: 13px; cursor: pointer; font-weight: 600;
57
+ }
58
+ button.ghost { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
59
+ button:hover { filter: brightness(1.08); }
60
+ button:disabled { opacity: .5; cursor: not-allowed; }
61
+ textarea, input, select {
62
+ width: 100%; background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
63
+ border-radius: 8px; padding: 9px 11px; font-size: 13px; font-family: inherit;
64
+ }
65
+ textarea { min-height: 90px; resize: vertical; }
66
+ label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
67
+ pre {
68
+ background: #0b0d11; border: 1px solid var(--border); border-radius: 10px;
69
+ padding: 12px; overflow: auto; font-size: 12px; color: #cbd5e1; max-height: 360px;
70
+ }
71
+ .empty { color: var(--muted); text-align: center; padding: 50px 0; }
72
+ .section { margin-top: 28px; }
73
+ .timeline-item { border-left: 2px solid var(--border); padding: 6px 0 6px 14px; margin-left: 6px; font-size: 13px; }
74
+ .timeline-item .t-meta { color: var(--muted); font-size: 11px; }
75
+ .toast { position: fixed; bottom: 20px; right: 20px; background: var(--panel-2); border: 1px solid var(--border); padding: 12px 16px; border-radius: 10px; font-size: 13px; max-width: 360px; }
@@ -0,0 +1,82 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Plugin SDK — Lattice AI</title>
7
+ <link rel="stylesheet" href="/static/platform.css" />
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <h1>Plugin SDK</h1>
12
+ <p class="sub" id="sub">Versioned, permissioned plugins that extend skills, tools, and workflows.</p>
13
+ <div id="list" class="grid"><div class="empty">Loading plugins…</div></div>
14
+
15
+ <div class="section">
16
+ <h3>Validate a manifest</h3>
17
+ <p class="sub">Paste a <code>plugin.json</code> to check it against the SDK schema and permission allow-list.</p>
18
+ <textarea id="manifest" spellcheck="false">{
19
+ "id": "my-plugin",
20
+ "name": "My Plugin",
21
+ "version": "1.0.0",
22
+ "lattice_version": ">=2.0.0",
23
+ "permissions": ["read_workspace"],
24
+ "provides": { "skills": [] }
25
+ }</textarea>
26
+ <div class="row" style="margin-top:10px"><button id="validate">Validate</button></div>
27
+ <pre id="validateOut" style="display:none"></pre>
28
+ </div>
29
+ </main>
30
+
31
+ <script type="module">
32
+ import { mountHeader, api, escapeHtml, badge, toast } from "/static/scripts/platform.js";
33
+ mountHeader("/plugins/sdk");
34
+
35
+ async function load() {
36
+ const data = await api("/plugins/registry");
37
+ document.getElementById("sub").textContent =
38
+ `SDK v${data.sdk_version} · ${data.total} plugin(s) discovered in ${data.plugins_dir}`;
39
+ const list = document.getElementById("list");
40
+ if (!data.plugins.length) { list.innerHTML = `<div class="empty">No plugins found.</div>`; return; }
41
+ list.innerHTML = data.plugins.map((p) => `
42
+ <div class="card">
43
+ <div class="row"><h3>${escapeHtml(p.name)}</h3><div class="spacer"></div>
44
+ ${badge(p.installed ? (p.enabled ? "ready" : "available") : "available")}</div>
45
+ <div class="meta">v${escapeHtml(p.version)} · ${escapeHtml(p.author || "unknown")} · ${p.compatible ? "compatible" : "<span class='badge err'>incompatible</span>"}</div>
46
+ <p style="font-size:13px;color:#cbd5e1">${escapeHtml(p.description)}</p>
47
+ <div class="meta">Permissions: ${(p.permissions||[]).map(x=>`<span class="badge">${escapeHtml(x)}</span>`).join(" ") || "none"}</div>
48
+ <div class="meta" style="margin-top:6px">Provides: ${Object.entries(p.provides||{}).map(([k,v])=>`${k}(${(v||[]).length})`).join(", ") || "—"}</div>
49
+ <div class="row" style="margin-top:12px">
50
+ ${p.installed
51
+ ? `<button class="ghost" data-act="${p.enabled?"disable":"enable"}" data-id="${p.id}">${p.enabled?"Disable":"Enable"}</button>
52
+ <button class="ghost" data-act="uninstall" data-id="${p.id}">Uninstall</button>`
53
+ : `<button data-act="install" data-id="${p.id}" ${p.compatible?"":"disabled"}>Install</button>`}
54
+ </div>
55
+ </div>`).join("");
56
+ }
57
+
58
+ document.getElementById("list").addEventListener("click", async (e) => {
59
+ const btn = e.target.closest("button[data-act]");
60
+ if (!btn) return;
61
+ btn.disabled = true;
62
+ try {
63
+ await api(`/plugins/${btn.dataset.act}`, { method: "POST", body: JSON.stringify({ plugin_id: btn.dataset.id }) });
64
+ toast(`${btn.dataset.act}: ${btn.dataset.id}`);
65
+ await load();
66
+ } catch (err) { toast(err.message); btn.disabled = false; }
67
+ });
68
+
69
+ document.getElementById("validate").addEventListener("click", async () => {
70
+ const out = document.getElementById("validateOut");
71
+ out.style.display = "block";
72
+ try {
73
+ const manifest = JSON.parse(document.getElementById("manifest").value);
74
+ const res = await api("/plugins/validate", { method: "POST", body: JSON.stringify({ manifest }) });
75
+ out.textContent = JSON.stringify(res, null, 2);
76
+ } catch (err) { out.textContent = "Error: " + err.message; }
77
+ });
78
+
79
+ load().catch((e) => toast(e.message));
80
+ </script>
81
+ </body>
82
+ </html>