ltcai 3.0.1 → 3.2.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.
- package/README.md +54 -21
- package/docs/CHANGELOG.md +90 -0
- package/docs/V3_2_AUDIT.md +82 -0
- package/docs/V3_FRONTEND.md +20 -17
- package/docs/architecture.md +6 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agent_registry.py +103 -0
- package/latticeai/api/auth.py +4 -1
- package/latticeai/api/hooks.py +113 -0
- package/latticeai/api/marketplace.py +13 -0
- package/latticeai/api/memory.py +109 -0
- package/latticeai/api/search.py +4 -0
- package/latticeai/core/agent_registry.py +234 -0
- package/latticeai/core/config.py +2 -0
- package/latticeai/core/embedding_providers.py +123 -0
- package/latticeai/core/hooks.py +284 -0
- package/latticeai/core/marketplace.py +87 -2
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/server_app.py +63 -6
- package/latticeai/services/memory_service.py +324 -0
- package/package.json +9 -4
- package/scripts/build_v3_assets.mjs +164 -0
- package/scripts/capture/README.md +28 -0
- package/scripts/capture/capture_enterprise.js +8 -0
- package/scripts/capture/capture_graph.js +8 -0
- package/scripts/capture/capture_onboarding.js +8 -0
- package/scripts/capture/capture_page.js +43 -0
- package/scripts/capture/capture_release_media.js +125 -0
- package/scripts/capture/capture_skills.js +8 -0
- package/scripts/capture/capture_workspace.js +8 -0
- package/scripts/generate_diagrams.py +513 -0
- package/scripts/lint_v3.mjs +33 -0
- package/scripts/release-0.3.1.sh +105 -0
- package/scripts/take_screenshots.js +69 -0
- package/scripts/validate_release_artifacts.py +167 -0
- package/static/account.html +9 -9
- package/static/activity.html +4 -4
- package/static/admin.html +8 -8
- package/static/agents.html +4 -4
- package/static/chat.html +9 -9
- package/static/css/tokens.5a595671.css +260 -0
- package/static/css/tokens.css +1 -1
- package/static/graph.html +9 -9
- package/static/plugins.html +4 -4
- package/static/sw.js +3 -1
- package/static/v3/asset-manifest.json +55 -0
- package/static/v3/css/lattice.base.e4cdd05d.css +128 -0
- package/static/v3/css/lattice.components.011e988b.css +447 -0
- package/static/v3/css/lattice.components.css +2 -2
- package/static/v3/css/lattice.shell.4920f42d.css +407 -0
- package/static/v3/css/lattice.tokens.c597ff81.css +132 -0
- package/static/v3/css/lattice.views.3ee19d4e.css +277 -0
- package/static/v3/index.html +38 -9
- package/static/v3/js/app.a5adc0f3.js +26 -0
- package/static/v3/js/core/api.603b978f.js +408 -0
- package/static/v3/js/core/api.js +132 -51
- package/static/v3/js/core/components.4c83e0a9.js +222 -0
- package/static/v3/js/core/components.js +9 -2
- package/static/v3/js/core/dom.a2773eb0.js +148 -0
- package/static/v3/js/core/router.584570f2.js +37 -0
- package/static/v3/js/core/routes.07ad6696.js +89 -0
- package/static/v3/js/core/routes.js +17 -1
- package/static/v3/js/core/shell.ea0b9ae5.js +363 -0
- package/static/v3/js/core/store.34ebd5e6.js +113 -0
- package/static/v3/js/views/admin-audit.660a1fb1.js +185 -0
- package/static/v3/js/views/admin-audit.js +1 -1
- package/static/v3/js/views/admin-permissions.a7ae5f09.js +177 -0
- package/static/v3/js/views/admin-permissions.js +4 -5
- package/static/v3/js/views/admin-policies.3658fd86.js +102 -0
- package/static/v3/js/views/admin-policies.js +4 -5
- package/static/v3/js/views/admin-private-vpc.7d342d36.js +135 -0
- package/static/v3/js/views/admin-private-vpc.js +2 -5
- package/static/v3/js/views/admin-security.07c66b72.js +180 -0
- package/static/v3/js/views/admin-security.js +4 -5
- package/static/v3/js/views/admin-users.03bac88c.js +168 -0
- package/static/v3/js/views/admin-users.js +6 -6
- package/static/v3/js/views/agents.c373d48c.js +293 -0
- package/static/v3/js/views/agents.js +101 -2
- package/static/v3/js/views/chat.718144ce.js +449 -0
- package/static/v3/js/views/chat.js +2 -3
- package/static/v3/js/views/files.4935197e.js +186 -0
- package/static/v3/js/views/files.js +27 -21
- package/static/v3/js/views/home.cdde3b32.js +119 -0
- package/static/v3/js/views/hooks.f3edebca.js +99 -0
- package/static/v3/js/views/hooks.js +99 -0
- package/static/v3/js/views/hybrid-search.b22b97e0.js +195 -0
- package/static/v3/js/views/hybrid-search.js +1 -1
- package/static/v3/js/views/knowledge-graph.a14ea7e7.js +237 -0
- package/static/v3/js/views/knowledge-graph.js +2 -3
- package/static/v3/js/views/marketplace.ab0583d4.js +141 -0
- package/static/v3/js/views/marketplace.js +141 -0
- package/static/v3/js/views/mcp.99b5c6a7.js +114 -0
- package/static/v3/js/views/mcp.js +114 -0
- package/static/v3/js/views/memory.d2ed7a7c.js +146 -0
- package/static/v3/js/views/memory.js +146 -0
- package/static/v3/js/views/models.a1ffa147.js +256 -0
- package/static/v3/js/views/models.js +17 -8
- package/static/v3/js/views/my-computer.1b2ff621.js +237 -0
- package/static/v3/js/views/my-computer.js +5 -5
- package/static/v3/js/views/pipeline.c522f1ce.js +157 -0
- package/static/v3/js/views/pipeline.js +3 -7
- package/static/v3/js/views/planning.9ac3e313.js +153 -0
- package/static/v3/js/views/planning.js +153 -0
- package/static/v3/js/views/settings.4f777210.js +250 -0
- package/static/v3/js/views/settings.js +6 -14
- package/static/v3/js/views/skills.c6c2f965.js +109 -0
- package/static/v3/js/views/skills.js +109 -0
- package/static/v3/js/views/tools.e4f11276.js +108 -0
- package/static/v3/js/views/tools.js +108 -0
- package/static/v3/js/views/workflows.26c57290.js +128 -0
- package/static/v3/js/views/workflows.js +128 -0
- package/static/workflows.html +4 -4
- package/static/workspace.html +5 -5
- package/docs/images/tmp_frames/frame_00.png +0 -0
- package/docs/images/tmp_frames/frame_01.png +0 -0
- package/docs/images/tmp_frames/frame_02.png +0 -0
- package/docs/images/tmp_frames/frame_03.png +0 -0
- package/docs/images/tmp_frames/hero_00.png +0 -0
- package/docs/images/tmp_frames/hero_01.png +0 -0
- package/docs/images/tmp_frames/hero_02.png +0 -0
- package/docs/images/tmp_frames/hero_03.png +0 -0
- package/static/v3/js/core/fixtures.js +0 -171
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { spawnSync } = require("child_process");
|
|
5
|
+
|
|
6
|
+
async function loadPlaywright() {
|
|
7
|
+
try {
|
|
8
|
+
return require("@playwright/test");
|
|
9
|
+
} catch (_) {
|
|
10
|
+
return require("playwright");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ROOT = path.resolve(__dirname, "..", "..");
|
|
15
|
+
const OUT = path.join(ROOT, "docs", "images");
|
|
16
|
+
const FRAMES = path.join(OUT, "tmp_frames", "release_221");
|
|
17
|
+
const BASE = process.env.LTCAI_CAPTURE_BASE_URL || "http://127.0.0.1:4825";
|
|
18
|
+
|
|
19
|
+
function ensureDirs() {
|
|
20
|
+
fs.mkdirSync(OUT, { recursive: true });
|
|
21
|
+
fs.rmSync(FRAMES, { recursive: true, force: true });
|
|
22
|
+
fs.mkdirSync(FRAMES, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function preparePage(page, theme = "light") {
|
|
26
|
+
await page.addInitScript((mode) => {
|
|
27
|
+
localStorage.setItem("lt-theme", mode);
|
|
28
|
+
localStorage.setItem("ltcai_workspace_type", "personal");
|
|
29
|
+
localStorage.setItem("ltcai_mode", "advanced");
|
|
30
|
+
localStorage.setItem("ltcai_user_email", "demo@lattice.local");
|
|
31
|
+
localStorage.setItem("ltcai_is_admin", "true");
|
|
32
|
+
sessionStorage.setItem("ltcai_force_setup_after_login", "false");
|
|
33
|
+
}, theme);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function gotoReady(page, pathname, selector, theme = "light") {
|
|
37
|
+
await preparePage(page, theme);
|
|
38
|
+
await page.goto(new URL(pathname, BASE).toString(), { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
39
|
+
if (selector) await page.waitForSelector(selector, { timeout: 20000 });
|
|
40
|
+
await page.evaluate((mode) => {
|
|
41
|
+
document.documentElement.setAttribute("data-lt-theme", mode);
|
|
42
|
+
document.body.dataset.capture = "release-221";
|
|
43
|
+
}, theme);
|
|
44
|
+
await page.waitForTimeout(1800);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function screenshot(page, filename, options = {}) {
|
|
48
|
+
const out = path.join(OUT, filename);
|
|
49
|
+
await page.screenshot({ path: out, fullPage: Boolean(options.fullPage) });
|
|
50
|
+
console.log(out);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function captureAll() {
|
|
54
|
+
ensureDirs();
|
|
55
|
+
const { chromium } = await loadPlaywright();
|
|
56
|
+
const browser = await chromium.launch({ headless: true });
|
|
57
|
+
|
|
58
|
+
const desktop = await browser.newContext({
|
|
59
|
+
viewport: { width: 1440, height: 920 },
|
|
60
|
+
deviceScaleFactor: 1,
|
|
61
|
+
});
|
|
62
|
+
const mobile = await browser.newContext({
|
|
63
|
+
viewport: { width: 390, height: 844 },
|
|
64
|
+
deviceScaleFactor: 2,
|
|
65
|
+
isMobile: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let page = await desktop.newPage();
|
|
69
|
+
await gotoReady(page, "/workspace", "#workspace-health-grid", "light");
|
|
70
|
+
await screenshot(page, "workspace-light.png");
|
|
71
|
+
await screenshot(page, "lattice-ai-hero.png");
|
|
72
|
+
|
|
73
|
+
await page.evaluate(() => document.documentElement.setAttribute("data-lt-theme", "dark"));
|
|
74
|
+
await page.waitForTimeout(800);
|
|
75
|
+
await screenshot(page, "workspace-dark.png");
|
|
76
|
+
|
|
77
|
+
await gotoReady(page, "/graph", "#graph", "dark");
|
|
78
|
+
await screenshot(page, "knowledge-graph.png");
|
|
79
|
+
|
|
80
|
+
await gotoReady(page, "/workflows", "#wfNodes", "light");
|
|
81
|
+
await screenshot(page, "pipeline.png");
|
|
82
|
+
|
|
83
|
+
await gotoReady(page, "/admin", "#admin-root, .admin-shell, body", "light");
|
|
84
|
+
await page.waitForTimeout(2200);
|
|
85
|
+
await screenshot(page, "admin-dashboard.png");
|
|
86
|
+
|
|
87
|
+
const mobilePage = await mobile.newPage();
|
|
88
|
+
await gotoReady(mobilePage, "/chat", ".reference-shell, body", "light");
|
|
89
|
+
await screenshot(mobilePage, "mobile-responsive.png", { fullPage: false });
|
|
90
|
+
|
|
91
|
+
const framePlan = [
|
|
92
|
+
["/workspace", "#workspace-health-grid", "light"],
|
|
93
|
+
["/workspace", "#workspace-health-grid", "dark"],
|
|
94
|
+
["/graph", "#graph", "dark"],
|
|
95
|
+
["/workflows", "#wfNodes", "light"],
|
|
96
|
+
["/chat", ".reference-shell, body", "light"],
|
|
97
|
+
];
|
|
98
|
+
let frame = 0;
|
|
99
|
+
for (const [pathname, selector, theme] of framePlan) {
|
|
100
|
+
await gotoReady(page, pathname, selector, theme);
|
|
101
|
+
for (let repeat = 0; repeat < 3; repeat += 1) {
|
|
102
|
+
await page.screenshot({ path: path.join(FRAMES, `frame_${String(frame).padStart(3, "0")}.png`) });
|
|
103
|
+
frame += 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await browser.close();
|
|
108
|
+
|
|
109
|
+
const gifPath = path.join(OUT, "lattice-ai-demo.gif");
|
|
110
|
+
const ffmpeg = spawnSync("ffmpeg", [
|
|
111
|
+
"-y",
|
|
112
|
+
"-framerate", "1.5",
|
|
113
|
+
"-i", path.join(FRAMES, "frame_%03d.png"),
|
|
114
|
+
"-vf", "scale=1280:-1:flags=lanczos,fps=6",
|
|
115
|
+
"-loop", "0",
|
|
116
|
+
gifPath,
|
|
117
|
+
], { stdio: "inherit" });
|
|
118
|
+
if (ffmpeg.status !== 0) process.exit(ffmpeg.status || 1);
|
|
119
|
+
console.log(gifPath);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
captureAll().catch((error) => {
|
|
123
|
+
console.error(error);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
});
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate Lattice AI documentation diagrams (PNG) + hero frames.
|
|
3
|
+
|
|
4
|
+
These are *structural diagrams* rendered from the live codebase — the
|
|
5
|
+
architecture layers, the onboarding flow, the real tri-state model
|
|
6
|
+
recommendation output, the real Knowledge Graph node/edge taxonomy, the
|
|
7
|
+
workspace/role model, and the skill-marketplace structure. They are deliberately
|
|
8
|
+
diagrams (not UI screenshots) so they stay accurate and reproducible in CI.
|
|
9
|
+
|
|
10
|
+
Run from the repo root:
|
|
11
|
+
|
|
12
|
+
python scripts/generate_diagrams.py
|
|
13
|
+
|
|
14
|
+
Outputs to ``docs/images/`` (and frame PNGs to ``docs/images/tmp_frames/`` which
|
|
15
|
+
the hero GIF is assembled from via ffmpeg by the caller).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
25
|
+
|
|
26
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
27
|
+
sys.path.insert(0, str(ROOT))
|
|
28
|
+
OUT = ROOT / "docs" / "images"
|
|
29
|
+
FRAMES = OUT / "tmp_frames"
|
|
30
|
+
|
|
31
|
+
# ── design system ─────────────────────────────────────────────────────────────
|
|
32
|
+
BG = (11, 17, 32)
|
|
33
|
+
PANEL = (23, 33, 58)
|
|
34
|
+
PANEL_LT = (32, 45, 74)
|
|
35
|
+
BORDER = (51, 65, 95)
|
|
36
|
+
TEXT = (226, 232, 240)
|
|
37
|
+
MUTED = (148, 163, 184)
|
|
38
|
+
ACCENT = (124, 92, 255)
|
|
39
|
+
ACCENT2 = (56, 189, 248)
|
|
40
|
+
GREEN = (34, 197, 94)
|
|
41
|
+
AMBER = (245, 158, 11)
|
|
42
|
+
GRAY = (107, 114, 128)
|
|
43
|
+
|
|
44
|
+
_F = "/System/Library/Fonts/Supplemental/Arial.ttf"
|
|
45
|
+
_FB = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
|
46
|
+
_FM = "/System/Library/Fonts/Menlo.ttc"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def font(size, bold=False, mono=False):
|
|
50
|
+
try:
|
|
51
|
+
return ImageFont.truetype(_FM if mono else (_FB if bold else _F), size)
|
|
52
|
+
except Exception:
|
|
53
|
+
return ImageFont.load_default()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def canvas(w, h, bg=BG):
|
|
57
|
+
img = Image.new("RGB", (w, h), bg)
|
|
58
|
+
return img, ImageDraw.Draw(img)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def rrect(d, box, r, fill=None, outline=None, width=2):
|
|
62
|
+
d.rounded_rectangle(box, radius=r, fill=fill, outline=outline, width=width)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def measure(d, s, f):
|
|
66
|
+
b = d.textbbox((0, 0), s, font=f)
|
|
67
|
+
return b[2] - b[0], b[3] - b[1]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def text(d, xy, s, f, fill=TEXT, anchor="la"):
|
|
71
|
+
d.text(xy, s, font=f, fill=fill, anchor=anchor)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def ctext(d, cx, y, s, f, fill=TEXT):
|
|
75
|
+
w, _ = measure(d, s, f)
|
|
76
|
+
d.text((cx - w / 2, y), s, font=f, fill=fill)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def badge(d, x, y, label, color, fsize=15):
|
|
80
|
+
f = font(fsize, bold=True)
|
|
81
|
+
w, h = measure(d, label, f)
|
|
82
|
+
pad = 9
|
|
83
|
+
box = [x, y, x + w + pad * 2, y + h + 9]
|
|
84
|
+
rrect(d, box, (h + 9) / 2, fill=color)
|
|
85
|
+
text(d, (x + pad, y + 4), label, f, fill=(11, 17, 32) if color in (GREEN, AMBER) else (255, 255, 255))
|
|
86
|
+
return box[2] - box[0]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def watermark(d, w, h):
|
|
90
|
+
f = font(15, bold=True)
|
|
91
|
+
text(d, (w - 200, h - 30), "Lattice AI", f, fill=MUTED)
|
|
92
|
+
d.ellipse([w - 222, h - 27, w - 208, h - 13], fill=ACCENT)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def header(d, w, title, subtitle):
|
|
96
|
+
text(d, (44, 34), title, font(34, bold=True), fill=TEXT)
|
|
97
|
+
text(d, (44, 80), subtitle, font(18), fill=MUTED)
|
|
98
|
+
d.line([(44, 116), (w - 44, 116)], fill=BORDER, width=2)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def save(img, name):
|
|
102
|
+
OUT.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
p = OUT / name
|
|
104
|
+
img.save(p)
|
|
105
|
+
print("wrote", p.relative_to(ROOT))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def wrap(d, s, f, max_w):
|
|
109
|
+
words, lines, cur = s.split(), [], ""
|
|
110
|
+
for word in words:
|
|
111
|
+
trial = (cur + " " + word).strip()
|
|
112
|
+
if measure(d, trial, f)[0] <= max_w:
|
|
113
|
+
cur = trial
|
|
114
|
+
else:
|
|
115
|
+
if cur:
|
|
116
|
+
lines.append(cur)
|
|
117
|
+
cur = word
|
|
118
|
+
if cur:
|
|
119
|
+
lines.append(cur)
|
|
120
|
+
return lines
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── 1. architecture ───────────────────────────────────────────────────────────
|
|
124
|
+
def diagram_architecture():
|
|
125
|
+
w, h = 1680, 1080
|
|
126
|
+
img, d = canvas(w, h)
|
|
127
|
+
header(d, w, "Lattice AI — Architecture (v1.5.0)",
|
|
128
|
+
"Thin compat entrypoint · modular FastAPI app · routers + services + core · local engines & knowledge graph")
|
|
129
|
+
|
|
130
|
+
def band(y, bh, title, items, accent, item_color=PANEL_LT):
|
|
131
|
+
rrect(d, [44, y, w - 44, y + bh], 14, fill=PANEL, outline=BORDER, width=2)
|
|
132
|
+
text(d, (60, y + 12), title, font(17, bold=True), fill=accent)
|
|
133
|
+
bx = 60
|
|
134
|
+
by = y + 44
|
|
135
|
+
fi = font(14, mono=True)
|
|
136
|
+
for it in items:
|
|
137
|
+
tw = measure(d, it, fi)[0] + 22
|
|
138
|
+
if bx + tw > w - 60:
|
|
139
|
+
bx = 60
|
|
140
|
+
by += 38
|
|
141
|
+
rrect(d, [bx, by, bx + tw, by + 28], 7, fill=item_color, outline=BORDER, width=1)
|
|
142
|
+
text(d, (bx + 11, by + 6), it, fi, fill=TEXT)
|
|
143
|
+
bx += tw + 10
|
|
144
|
+
|
|
145
|
+
def arrow_down(cx, y1, y2):
|
|
146
|
+
d.line([(cx, y1), (cx, y2)], fill=ACCENT2, width=3)
|
|
147
|
+
d.polygon([(cx - 6, y2 - 9), (cx + 6, y2 - 9), (cx, y2)], fill=ACCENT2)
|
|
148
|
+
|
|
149
|
+
band(140, 92, "CLIENTS",
|
|
150
|
+
["Web UI (static/)", "VS Code / Cursor (vscode-extension/)", "Telegram bot", "MCP clients", "PWA"],
|
|
151
|
+
ACCENT2)
|
|
152
|
+
arrow_down(w // 2, 232, 258)
|
|
153
|
+
|
|
154
|
+
band(258, 78, "ENTRYPOINT",
|
|
155
|
+
["server.py → server:app (thin compat)", "latticeai/server_app.py (FastAPI assembly · lifespan · middleware · static mount · router wiring)"],
|
|
156
|
+
ACCENT)
|
|
157
|
+
arrow_down(w // 2, 336, 362)
|
|
158
|
+
|
|
159
|
+
band(362, 130, "API ROUTERS latticeai/api/",
|
|
160
|
+
["chat", "models", "workspace", "mcp", "admin", "auth", "tools", "computer_use",
|
|
161
|
+
"local_files", "permissions", "garden", "setup", "static_routes", "health", "security_dashboard"],
|
|
162
|
+
ACCENT2)
|
|
163
|
+
arrow_down(w // 2, 492, 518)
|
|
164
|
+
|
|
165
|
+
band(518, 112, "SERVICES latticeai/services/",
|
|
166
|
+
["model_runtime", "model_catalog", "model_recommendation", "tool_dispatch", "upload_service",
|
|
167
|
+
"chat_service", "workspace_service", "model_service", "app_context"],
|
|
168
|
+
ACCENT)
|
|
169
|
+
arrow_down(w // 2, 630, 656)
|
|
170
|
+
|
|
171
|
+
band(656, 130, "CORE latticeai/core/",
|
|
172
|
+
["workspace_os", "tool_registry", "agent", "enterprise", "enterprise_admin", "security",
|
|
173
|
+
"sessions", "audit", "config", "model_compat", "model_resolution", "graph_curator"],
|
|
174
|
+
ACCENT2)
|
|
175
|
+
arrow_down(w // 2, 786, 812)
|
|
176
|
+
|
|
177
|
+
# bottom: engines + graph + storage as three columns
|
|
178
|
+
cols = [
|
|
179
|
+
("ENGINES llm_router.py", ["MLX (Apple Silicon)", "Ollama", "vLLM", "llama.cpp", "LM Studio", "OpenAI-compatible"]),
|
|
180
|
+
("KNOWLEDGE GRAPH", ["knowledge_graph.py", "KGStore v2 (SQLite)", "Graph RAG", "graph_curator"]),
|
|
181
|
+
("STORAGE / MCP", ["~/.ltcai/ (local)", "~/.ltcai-brain/", "mcp_registry.py", "skills/"]),
|
|
182
|
+
]
|
|
183
|
+
cw = (w - 44 * 2 - 24 * 2) // 3
|
|
184
|
+
for i, (title, items) in enumerate(cols):
|
|
185
|
+
x = 44 + i * (cw + 24)
|
|
186
|
+
rrect(d, [x, 812, x + cw, 980], 14, fill=PANEL, outline=BORDER, width=2)
|
|
187
|
+
text(d, (x + 16, 824), title, font(15, bold=True), fill=ACCENT)
|
|
188
|
+
fy = 858
|
|
189
|
+
fi = font(14, mono=True)
|
|
190
|
+
for it in items:
|
|
191
|
+
d.ellipse([x + 16, fy + 5, x + 24, fy + 13], fill=ACCENT2)
|
|
192
|
+
text(d, (x + 32, fy), it, fi, fill=TEXT)
|
|
193
|
+
fy += 28
|
|
194
|
+
|
|
195
|
+
watermark(d, w, h)
|
|
196
|
+
save(img, "architecture.png")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── 2. onboarding flow ──────────────────────────────────────────────────────--
|
|
200
|
+
def diagram_onboarding():
|
|
201
|
+
w, h = 1680, 620
|
|
202
|
+
img, d = canvas(w, h)
|
|
203
|
+
header(d, w, "Onboarding Flow",
|
|
204
|
+
"From install to first chat — system scan and model recommendation are built in")
|
|
205
|
+
steps = [
|
|
206
|
+
("1", "Install", "pip install ltcai", ACCENT2),
|
|
207
|
+
("2", "System Scan", "OS · CPU · GPU · RAM · Disk", ACCENT),
|
|
208
|
+
("3", "Model Recommendation", "Recommended / Compatible / Not", GREEN),
|
|
209
|
+
("4", "Workspace", "Personal or Organization", ACCENT2),
|
|
210
|
+
("5", "Indexing", "approve local folders", ACCENT),
|
|
211
|
+
("6", "Knowledge Graph", "nodes & edges auto-built", ACCENT2),
|
|
212
|
+
("7", "First Chat", "graph-aware answers", GREEN),
|
|
213
|
+
]
|
|
214
|
+
n = len(steps)
|
|
215
|
+
margin = 44
|
|
216
|
+
gap = 22
|
|
217
|
+
bw = (w - margin * 2 - gap * (n - 1)) // n
|
|
218
|
+
y = 200
|
|
219
|
+
bh = 220
|
|
220
|
+
for i, (num, title, sub, accent) in enumerate(steps):
|
|
221
|
+
x = margin + i * (bw + gap)
|
|
222
|
+
rrect(d, [x, y, x + bw, y + bh], 14, fill=PANEL, outline=BORDER, width=2)
|
|
223
|
+
d.ellipse([x + bw / 2 - 22, y + 22, x + bw / 2 + 22, y + 66], fill=accent)
|
|
224
|
+
ctext(d, x + bw / 2, y + 30, num, font(24, bold=True), fill=(11, 17, 32))
|
|
225
|
+
for j, line in enumerate(wrap(d, title, font(17, bold=True), bw - 20)):
|
|
226
|
+
ctext(d, x + bw / 2, y + 90 + j * 24, line, font(17, bold=True), fill=TEXT)
|
|
227
|
+
for j, line in enumerate(wrap(d, sub, font(13), bw - 24)):
|
|
228
|
+
ctext(d, x + bw / 2, y + 150 + j * 20, line, font(13), fill=MUTED)
|
|
229
|
+
if i < n - 1:
|
|
230
|
+
ax = x + bw + 4
|
|
231
|
+
d.line([(ax, y + bh / 2), (ax + gap - 8, y + bh / 2)], fill=ACCENT2, width=3)
|
|
232
|
+
d.polygon([(ax + gap - 12, y + bh / 2 - 6), (ax + gap - 12, y + bh / 2 + 6), (ax + gap - 2, y + bh / 2)], fill=ACCENT2)
|
|
233
|
+
text(d, (margin, y + bh + 40),
|
|
234
|
+
"Each step writes onboarding state via /workspace/onboarding/* so progress is resumable.",
|
|
235
|
+
font(15), fill=MUTED)
|
|
236
|
+
watermark(d, w, h)
|
|
237
|
+
save(img, "onboarding.png")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ── 3. model recommendation (live data) ─────────────────────────────────────--
|
|
241
|
+
def diagram_model_recommendation():
|
|
242
|
+
from latticeai.services.model_recommendation import recommend_catalog, RECOMMENDED, COMPATIBLE
|
|
243
|
+
# Representative machine: 32 GB Apple Silicon.
|
|
244
|
+
profile = {"os": "darwin", "arch": "arm64", "ram_mb": 32 * 1024,
|
|
245
|
+
"gpu": {"vendor": "apple", "vram_mb": 32 * 1024}}
|
|
246
|
+
rec = recommend_catalog(profile, engine="local_mlx")
|
|
247
|
+
|
|
248
|
+
w, h = 1680, 1020
|
|
249
|
+
img, d = canvas(w, h)
|
|
250
|
+
header(d, w, "Local Model Recommendation",
|
|
251
|
+
"Hardware-aware tri-state classification — example: 32 GB Apple Silicon (MLX)")
|
|
252
|
+
|
|
253
|
+
# legend
|
|
254
|
+
lx = 44
|
|
255
|
+
lx += badge(d, lx, 130, "Recommended", GREEN) + 14
|
|
256
|
+
lx += badge(d, lx, 130, "Compatible", AMBER) + 14
|
|
257
|
+
lx += badge(d, lx, 130, "Not Recommended", GRAY) + 14
|
|
258
|
+
counts = rec["counts"]
|
|
259
|
+
text(d, (w - 520, 134),
|
|
260
|
+
f"recommended {counts['recommended']} · compatible {counts['compatible']} · not {counts['not_recommended']}",
|
|
261
|
+
font(15, mono=True), fill=MUTED)
|
|
262
|
+
|
|
263
|
+
# show top families per directive
|
|
264
|
+
want = ["Gemma 4", "Qwen3-VL", "Llama 4"]
|
|
265
|
+
fams = [f for f in rec["families"] if f["family"] in want][:6]
|
|
266
|
+
y = 176
|
|
267
|
+
fi = font(15)
|
|
268
|
+
fm = font(13, mono=True)
|
|
269
|
+
for fam in fams:
|
|
270
|
+
models = fam["models"][:5]
|
|
271
|
+
rh = 40 + len(models) * 30 + 16
|
|
272
|
+
rrect(d, [44, y, w - 44, y + rh], 12, fill=PANEL, outline=BORDER, width=2)
|
|
273
|
+
text(d, (60, y + 12), fam["family"], font(18, bold=True), fill=ACCENT2)
|
|
274
|
+
best = fam.get("best")
|
|
275
|
+
if best:
|
|
276
|
+
badge(d, 60 + measure(d, fam["family"], font(18, bold=True))[0] + 18, y + 12,
|
|
277
|
+
"best: " + (best["name"] or ""), GREEN, fsize=13)
|
|
278
|
+
ry = y + 46
|
|
279
|
+
for m in models:
|
|
280
|
+
color = {RECOMMENDED: GREEN, COMPATIBLE: AMBER}.get(m["status"], GRAY)
|
|
281
|
+
d.ellipse([62, ry + 5, 74, ry + 17], fill=color)
|
|
282
|
+
text(d, (86, ry), m["name"] or m["id"], fi, fill=TEXT)
|
|
283
|
+
text(d, (w - 360, ry), str(m["size"] or ""), fm, fill=MUTED)
|
|
284
|
+
badge(d, w - 230, ry - 2, {"recommended": "Recommended", "compatible": "Compatible"}.get(m["status"], "Not Rec."), color, fsize=12)
|
|
285
|
+
ry += 30
|
|
286
|
+
y += rh + 14
|
|
287
|
+
|
|
288
|
+
watermark(d, w, h)
|
|
289
|
+
save(img, "model-recommendation.png")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ── 4. workspace model ──────────────────────────────────────────────────────--
|
|
293
|
+
def diagram_workspace():
|
|
294
|
+
w, h = 1680, 760
|
|
295
|
+
img, d = canvas(w, h)
|
|
296
|
+
header(d, w, "Workspaces — Personal & Organization",
|
|
297
|
+
"Switch instantly · workspace-scoped data · explicit active-workspace state")
|
|
298
|
+
|
|
299
|
+
def col(x, title, accent, lines):
|
|
300
|
+
cw = (w - 44 * 2 - 40) // 2
|
|
301
|
+
rrect(d, [x, 150, x + cw, 600], 16, fill=PANEL, outline=accent, width=2)
|
|
302
|
+
text(d, (x + 22, 168), title, font(22, bold=True), fill=accent)
|
|
303
|
+
fy = 218
|
|
304
|
+
for icon, t, sub in lines:
|
|
305
|
+
d.ellipse([x + 22, fy + 4, x + 38, fy + 20], fill=accent)
|
|
306
|
+
text(d, (x + 52, fy), t, font(16, bold=True), fill=TEXT)
|
|
307
|
+
text(d, (x + 52, fy + 24), sub, font(13), fill=MUTED)
|
|
308
|
+
fy += 62
|
|
309
|
+
return cw
|
|
310
|
+
|
|
311
|
+
cw = col(44, "Personal Workspace", ACCENT2, [
|
|
312
|
+
("", "Single owner", "no-auth local owner fallback"),
|
|
313
|
+
("", "Private data", "snapshots · memory · agents · traces"),
|
|
314
|
+
("", "Local-first", "stored under ~/.ltcai"),
|
|
315
|
+
("", "Instant default", "active on first run"),
|
|
316
|
+
])
|
|
317
|
+
col(44 + cw + 40, "Organization Workspace", ACCENT, [
|
|
318
|
+
("", "Roles", "owner · admin · member · viewer"),
|
|
319
|
+
("", "Shared & scoped", "data carries workspace_id"),
|
|
320
|
+
("", "Membership", "invite, manage, archive (non-destructive)"),
|
|
321
|
+
("", "Visibility", "active workspace + role shown in header"),
|
|
322
|
+
])
|
|
323
|
+
|
|
324
|
+
rrect(d, [44, 624, w - 44, 700], 12, fill=PANEL_LT, outline=BORDER, width=2)
|
|
325
|
+
text(d, (60, 636), "Scoping", font(15, bold=True), fill=ACCENT2)
|
|
326
|
+
text(d, (60, 662),
|
|
327
|
+
"Reads/writes resolve scope via the X-Workspace-Id header → WorkspaceService gate "
|
|
328
|
+
"(non-members blocked, viewers read-only, owners/admins manage).",
|
|
329
|
+
font(14, mono=True), fill=TEXT)
|
|
330
|
+
watermark(d, w, h)
|
|
331
|
+
save(img, "workspace.png")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ── 5. knowledge graph taxonomy (live data) ─────────────────────────────────--
|
|
335
|
+
def diagram_graph():
|
|
336
|
+
import kg_schema as k
|
|
337
|
+
nodes = [e.value.title().replace("_", " ") for e in k.NodeType][:18]
|
|
338
|
+
edges = [e.value.lower() for e in k.EdgeType][:18]
|
|
339
|
+
|
|
340
|
+
w, h = 1680, 820
|
|
341
|
+
img, d = canvas(w, h)
|
|
342
|
+
header(d, w, "Knowledge Graph — Taxonomy",
|
|
343
|
+
f"{len(list(k.NodeType))} node types · {len(list(k.EdgeType))} edge types · auto-built from chats, files & folders")
|
|
344
|
+
|
|
345
|
+
# center hub + sample relationship
|
|
346
|
+
cx, cy = w // 2, 300
|
|
347
|
+
rrect(d, [cx - 90, cy - 34, cx + 90, cy + 34], 16, fill=ACCENT, outline=None)
|
|
348
|
+
ctext(d, cx, cy - 12, "Document", font(18, bold=True), fill=(255, 255, 255))
|
|
349
|
+
sat = [("Concept", -360, -120, ACCENT2, "mentions"),
|
|
350
|
+
("Person", 360, -120, ACCENT2, "authored_by"),
|
|
351
|
+
("Chat", -360, 120, GREEN, "references"),
|
|
352
|
+
("Task", 360, 120, AMBER, "contains")]
|
|
353
|
+
for label, dx, dy, color, rel in sat:
|
|
354
|
+
sx, sy = cx + dx, cy + dy
|
|
355
|
+
d.line([(cx, cy), (sx, sy)], fill=BORDER, width=2)
|
|
356
|
+
mx, my = (cx + sx) / 2, (cy + sy) / 2
|
|
357
|
+
text(d, (mx, my - 18), rel, font(12, mono=True), fill=MUTED, anchor="ma")
|
|
358
|
+
rrect(d, [sx - 70, sy - 26, sx + 70, sy + 26], 12, fill=PANEL, outline=color, width=2)
|
|
359
|
+
ctext(d, sx, sy - 9, label, font(15, bold=True), fill=color)
|
|
360
|
+
|
|
361
|
+
# node / edge type chips
|
|
362
|
+
def chips(y, title, items, color):
|
|
363
|
+
text(d, (44, y), title, font(16, bold=True), fill=color)
|
|
364
|
+
bx, by = 44, y + 30
|
|
365
|
+
fi = font(13, mono=True)
|
|
366
|
+
for it in items:
|
|
367
|
+
tw = measure(d, it, fi)[0] + 20
|
|
368
|
+
if bx + tw > w - 44:
|
|
369
|
+
bx = 44
|
|
370
|
+
by += 34
|
|
371
|
+
rrect(d, [bx, by, bx + tw, by + 26], 7, fill=PANEL, outline=color, width=1)
|
|
372
|
+
text(d, (bx + 10, by + 5), it, fi, fill=TEXT)
|
|
373
|
+
bx += tw + 9
|
|
374
|
+
return by + 40
|
|
375
|
+
|
|
376
|
+
ny = chips(470, "Node types", nodes, ACCENT2)
|
|
377
|
+
chips(ny + 6, "Edge types", edges, GREEN)
|
|
378
|
+
watermark(d, w, h)
|
|
379
|
+
save(img, "graph.png")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ── 6. organization roles × permissions ─────────────────────────────────────--
|
|
383
|
+
def diagram_organization():
|
|
384
|
+
w, h = 1680, 720
|
|
385
|
+
img, d = canvas(w, h)
|
|
386
|
+
header(d, w, "Organization — Roles & Permissions",
|
|
387
|
+
"Member visibility, role clarity, and a transparent permission matrix")
|
|
388
|
+
|
|
389
|
+
roles = ["owner", "admin", "member", "viewer"]
|
|
390
|
+
perms = ["read", "write", "manage_members", "manage_workspace"]
|
|
391
|
+
grant = {
|
|
392
|
+
"owner": {"read", "write", "manage_members", "manage_workspace"},
|
|
393
|
+
"admin": {"read", "write", "manage_members"},
|
|
394
|
+
"member": {"read", "write"},
|
|
395
|
+
"viewer": {"read"},
|
|
396
|
+
}
|
|
397
|
+
x0, y0 = 60, 180
|
|
398
|
+
col_w, row_h = 360, 92
|
|
399
|
+
# header row
|
|
400
|
+
for j, p in enumerate(perms):
|
|
401
|
+
cx = x0 + col_w + j * 300 + 150
|
|
402
|
+
ctext(d, cx, y0 - 4, p, font(15, bold=True), fill=ACCENT2)
|
|
403
|
+
for i, r in enumerate(roles):
|
|
404
|
+
ry = y0 + 30 + i * row_h
|
|
405
|
+
rrect(d, [x0, ry, x0 + col_w, ry + row_h - 16], 12, fill=PANEL, outline=ACCENT, width=2)
|
|
406
|
+
text(d, (x0 + 18, ry + 16), r, font(20, bold=True), fill=TEXT)
|
|
407
|
+
text(d, (x0 + 18, ry + 46), {"owner": "full control", "admin": "manage members",
|
|
408
|
+
"member": "create & edit", "viewer": "read-only"}[r], font(13), fill=MUTED)
|
|
409
|
+
for j, p in enumerate(perms):
|
|
410
|
+
cx = x0 + col_w + j * 300 + 150
|
|
411
|
+
cy = ry + (row_h - 16) / 2
|
|
412
|
+
if p in grant[r]:
|
|
413
|
+
d.ellipse([cx - 16, cy - 16, cx + 16, cy + 16], fill=GREEN)
|
|
414
|
+
ctext(d, cx, cy - 12, "✓", font(20, bold=True), fill=(11, 17, 32))
|
|
415
|
+
else:
|
|
416
|
+
d.ellipse([cx - 16, cy - 16, cx + 16, cy + 16], outline=GRAY, width=2)
|
|
417
|
+
watermark(d, w, h)
|
|
418
|
+
save(img, "organization.png")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ── 7. skill marketplace ────────────────────────────────────────────────────--
|
|
422
|
+
def diagram_skills():
|
|
423
|
+
w, h = 1680, 720
|
|
424
|
+
img, d = canvas(w, h)
|
|
425
|
+
header(d, w, "Skill Marketplace",
|
|
426
|
+
"Browse, install, and keep skills current — Recommended · Popular · Installed · Updates")
|
|
427
|
+
|
|
428
|
+
tabs = [("Recommended", ACCENT2), ("Popular", ACCENT), ("Installed", GREEN), ("Updates Available", AMBER)]
|
|
429
|
+
tx = 44
|
|
430
|
+
for label, color in tabs:
|
|
431
|
+
tw = measure(d, label, font(16, bold=True))[0] + 36
|
|
432
|
+
rrect(d, [tx, 150, tx + tw, 196], 10, fill=PANEL, outline=color, width=2)
|
|
433
|
+
text(d, (tx + 18, 162), label, font(16, bold=True), fill=color)
|
|
434
|
+
tx += tw + 14
|
|
435
|
+
|
|
436
|
+
cards = [
|
|
437
|
+
("code-reviewer", "Recommended", ACCENT2, "Review diffs for bugs & risks"),
|
|
438
|
+
("docs-writer", "Popular", ACCENT, "Generate project documentation"),
|
|
439
|
+
("changelog-generator", "Installed", GREEN, "Changelog from git history"),
|
|
440
|
+
("security-review", "Updates Available", AMBER, "Scan code for vulnerabilities"),
|
|
441
|
+
("react-best-practices", "Recommended", ACCENT2, "React/Next performance"),
|
|
442
|
+
("deep-research", "Popular", ACCENT, "Multi-source cited research"),
|
|
443
|
+
]
|
|
444
|
+
cw, ch, gap = 520, 150, 24
|
|
445
|
+
for i, (name, tag, color, desc) in enumerate(cards):
|
|
446
|
+
cxi = i % 3
|
|
447
|
+
cyi = i // 3
|
|
448
|
+
x = 44 + cxi * (cw + gap)
|
|
449
|
+
y = 230 + cyi * (ch + gap)
|
|
450
|
+
rrect(d, [x, y, x + cw, y + ch], 14, fill=PANEL, outline=BORDER, width=2)
|
|
451
|
+
text(d, (x + 20, y + 18), name, font(19, bold=True), fill=TEXT)
|
|
452
|
+
badge(d, x + 20, y + 54, tag, color, fsize=12)
|
|
453
|
+
for j, line in enumerate(wrap(d, desc, font(14), cw - 40)):
|
|
454
|
+
text(d, (x + 20, y + 92 + j * 20), line, font(14), fill=MUTED)
|
|
455
|
+
text(d, (44, 230 + 2 * (ch + gap)),
|
|
456
|
+
"Lifecycle: install · enable · disable · update · uninstall (admin-gated, audited via /workspace/skills/*)",
|
|
457
|
+
font(15), fill=MUTED)
|
|
458
|
+
watermark(d, w, h)
|
|
459
|
+
save(img, "skills.png")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ── 8. hero frames (assembled into hero.gif by ffmpeg) ──────────────────────--
|
|
463
|
+
def hero_frames():
|
|
464
|
+
FRAMES.mkdir(parents=True, exist_ok=True)
|
|
465
|
+
stages = [
|
|
466
|
+
("Local files · chats · folders", "your workspace, indexed locally", ACCENT2),
|
|
467
|
+
("Automatic Knowledge Graph", "nodes & edges built from real work", ACCENT),
|
|
468
|
+
("Graph-aware chat & agents", "answers grounded in your memory", GREEN),
|
|
469
|
+
("One local AI Workspace OS", "private · local-first · yours", ACCENT2),
|
|
470
|
+
]
|
|
471
|
+
w, h = 1280, 640
|
|
472
|
+
for idx in range(len(stages)):
|
|
473
|
+
img, d = canvas(w, h)
|
|
474
|
+
# title
|
|
475
|
+
ctext(d, w // 2, 60, "Lattice AI", font(40, bold=True), fill=TEXT)
|
|
476
|
+
ctext(d, w // 2, 112, "AI Workspace OS for local-first graph, memory & agents", font(18), fill=MUTED)
|
|
477
|
+
# pipeline dots
|
|
478
|
+
n = len(stages)
|
|
479
|
+
cxs = [w // 2 - 420, w // 2 - 140, w // 2 + 140, w // 2 + 420]
|
|
480
|
+
labels = ["Files", "Graph", "Chat", "OS"]
|
|
481
|
+
for i in range(n):
|
|
482
|
+
active = i <= idx
|
|
483
|
+
color = stages[i][2] if active else PANEL_LT
|
|
484
|
+
r = 30 if active else 22
|
|
485
|
+
d.ellipse([cxs[i] - r, 230 - r, cxs[i] + r, 230 + r], fill=color)
|
|
486
|
+
ctext(d, cxs[i], 270, labels[i], font(15, bold=True), fill=TEXT if active else MUTED)
|
|
487
|
+
if i < n - 1:
|
|
488
|
+
lc = ACCENT2 if i < idx else BORDER
|
|
489
|
+
d.line([(cxs[i] + 34, 230), (cxs[i + 1] - 34, 230)], fill=lc, width=4)
|
|
490
|
+
# active stage card
|
|
491
|
+
title, sub, color = stages[idx]
|
|
492
|
+
rrect(d, [w // 2 - 460, 360, w // 2 + 460, 520], 18, fill=PANEL, outline=color, width=3)
|
|
493
|
+
ctext(d, w // 2, 396, title, font(30, bold=True), fill=color)
|
|
494
|
+
ctext(d, w // 2, 446, sub, font(18), fill=TEXT)
|
|
495
|
+
watermark(d, w, h)
|
|
496
|
+
p = FRAMES / f"frame_{idx:02d}.png"
|
|
497
|
+
img.save(p)
|
|
498
|
+
print("wrote", p.relative_to(ROOT))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def main():
|
|
502
|
+
diagram_architecture()
|
|
503
|
+
diagram_onboarding()
|
|
504
|
+
diagram_model_recommendation()
|
|
505
|
+
diagram_workspace()
|
|
506
|
+
diagram_graph()
|
|
507
|
+
diagram_organization()
|
|
508
|
+
diagram_skills()
|
|
509
|
+
hero_frames()
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
if __name__ == "__main__":
|
|
513
|
+
main()
|