ltcai 0.6.0 → 1.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "0.6.0",
4
- "description": "Lattice AI local MLX/cloud LLM workspace server",
3
+ "version": "1.0.0",
4
+ "description": "Lattice AI Workspace OS for local-first graph, memory, agent, workflow, and skill operations",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
7
7
  "type": "git",
@@ -17,8 +17,9 @@
17
17
  "scripts": {
18
18
  "start": "LTCAI",
19
19
  "dev": "python3 ltcai_cli.py --reload",
20
+ "build": "npm run build:python",
20
21
  "build:python": "python3 -m build",
21
- "check:python": "python3 -m py_compile ltcai_cli.py server.py latticeai/server_app.py latticeai/core/tool_registry.py latticeai/core/agent_prompts.py knowledge_graph.py knowledge_graph_api.py local_knowledge_api.py llm_router.py p_reinforce.py telegram_bot.py tools.py codex_telegram_bot.py",
22
+ "check:python": "python3 -m py_compile ltcai_cli.py server.py latticeai/server_app.py latticeai/core/tool_registry.py latticeai/core/agent_prompts.py latticeai/core/workspace_os.py knowledge_graph.py knowledge_graph_api.py local_knowledge_api.py llm_router.py p_reinforce.py telegram_bot.py tools.py codex_telegram_bot.py",
22
23
  "test": "python3 -m pytest tests/ -v",
23
24
  "test:unit": "python3 -m pytest tests/unit/ -v",
24
25
  "test:integration": "python3 -m pytest tests/integration/ -v",
@@ -60,9 +61,11 @@
60
61
  "static/chat.html",
61
62
  "static/admin.html",
62
63
  "static/graph.html",
64
+ "static/workspace.html",
63
65
  "static/manifest.json",
64
66
  "static/sw.js",
65
67
  "static/lattice-reference.css",
68
+ "static/workspace.css",
66
69
  "static/scripts/",
67
70
  "static/css/",
68
71
  "static/icons/",
package/static/admin.html CHANGED
@@ -28,6 +28,7 @@
28
28
  <a href="#sso" data-admin-nav="sso"><i class="ti ti-lock-access"></i> <span data-i18n="nav_sso">SSO 관리</span></a>
29
29
  <a href="#security" data-admin-nav="security"><i class="ti ti-shield-check"></i> <span data-i18n="nav_security">보안 모니터링</span></a>
30
30
  <a href="#audit" data-admin-nav="audit"><i class="ti ti-report-search"></i> <span data-i18n="nav_audit">감사 로그</span></a>
31
+ <a href="/workspace"><i class="ti ti-layout-dashboard"></i> <span>Workspace OS</span></a>
31
32
  <a href="/chat"><i class="ti ti-message-circle"></i> <span data-i18n="nav_chat">채팅으로</span></a>
32
33
  </nav>
33
34
  <div class="rail-project">
package/static/graph.html CHANGED
@@ -15,6 +15,7 @@
15
15
  <div class="rail-brand"><i class="ti ti-chart-dots-3"></i><strong>Lattice AI</strong></div>
16
16
  <nav>
17
17
  <a href="/chat"><i class="ti ti-home"></i> 홈</a>
18
+ <a href="/workspace"><i class="ti ti-layout-dashboard"></i> Workspace OS</a>
18
19
  <a class="active" href="/graph"><i class="ti ti-chart-dots-3"></i> 지식 그래프</a>
19
20
  <a href="/chat"><i class="ti ti-message-circle"></i> 대화</a>
20
21
  <a href="/chat"><i class="ti ti-file"></i> 파일</a>
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "Lattice AI",
3
3
  "short_name": "LatticeAI",
4
- "description": "Local AI agent workspace MLX & cloud LLMs",
5
- "start_url": "/",
4
+ "description": "AI Workspace OS for local-first graph, memory, agents, workflows, and skills",
5
+ "start_url": "/workspace",
6
6
  "id": "/",
7
7
  "display": "standalone",
8
8
  "orientation": "any",
@@ -225,7 +225,7 @@ const chatViewport = document.getElementById('chat-viewport');
225
225
  // 헤더 / 사이드바
226
226
  logout: '로그아웃', admin_dashboard: '관리자 대시보드',
227
227
  my_status: '내 상태 보기', auto_setup: '자동 설정',
228
- nav_home: '홈', nav_chat: '채팅', nav_knowledge: '지식 그래프',
228
+ nav_home: '홈', nav_chat: '채팅', nav_workspace: 'Workspace OS', nav_knowledge: '지식 그래프',
229
229
  nav_pipeline: '파이프라인', nav_files: '내 컴퓨터',
230
230
  nav_model_status: '모델 상태', nav_runtime: '런타임 설정',
231
231
  nav_advanced_settings: '고급 설정',
@@ -304,7 +304,7 @@ const chatViewport = document.getElementById('chat-viewport');
304
304
  // Header / Sidebar
305
305
  logout: 'Logout', admin_dashboard: 'Admin Dashboard',
306
306
  my_status: 'My Status', auto_setup: 'Auto Setup',
307
- nav_home: 'Home', nav_chat: 'Chat', nav_knowledge: 'Knowledge Graph',
307
+ nav_home: 'Home', nav_chat: 'Chat', nav_workspace: 'Workspace OS', nav_knowledge: 'Knowledge Graph',
308
308
  nav_pipeline: 'Pipeline', nav_files: 'My Computer',
309
309
  nav_model_status: 'Model Status', nav_runtime: 'Runtime Settings',
310
310
  nav_advanced_settings: 'Advanced Settings',
@@ -559,6 +559,7 @@ const chatViewport = document.getElementById('chat-viewport');
559
559
  const BASE_NAV_ITEMS = [
560
560
  { id: 'home', icon: 'ti-home', labelKey: 'nav_home' },
561
561
  { id: 'chat', icon: 'ti-message-circle', labelKey: 'nav_chat' },
562
+ { id: 'workspace-os', icon: 'ti-layout-dashboard', labelKey: 'nav_workspace' },
562
563
  { id: 'knowledge', icon: 'ti-chart-dots-3', labelKey: 'nav_knowledge' },
563
564
  { id: 'pipeline', icon: 'ti-git-branch', labelKey: 'nav_pipeline' },
564
565
  { id: 'files', icon: 'ti-device-desktop', labelKey: 'nav_files' },
@@ -622,6 +623,7 @@ const chatViewport = document.getElementById('chat-viewport');
622
623
  closeSidebar();
623
624
  if (id === 'home') showHome();
624
625
  else if (id === 'chat') showChat();
626
+ else if (id === 'workspace-os') window.location.href = `${API_BASE}/workspace`;
625
627
  else if (id === 'knowledge') openDataGraph();
626
628
  else if (id === 'pipeline') openPipelineModal();
627
629
  else if (id === 'files') openLocalBrowser();
@@ -4,7 +4,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
4
4
 
5
5
  const G18N = {
6
6
  ko: {
7
- nav_home: '홈', nav_graph: '지식 그래프', nav_chat: '대화', nav_files: '파일', nav_code: '코드', nav_settings: '설정',
7
+ nav_home: '홈', nav_workspace: 'Workspace OS', nav_graph: '지식 그래프', nav_chat: '대화', nav_files: '파일', nav_code: '코드', nav_settings: '설정',
8
8
  project: '프로젝트', search_title: '그래프 탐색', search_sub: '주제, 파일, 대화, 결정, 작업을 검색하세요.',
9
9
  ready: '준비됨', search_ph: '주제, 파일, 대화로 검색...', clear_search: '검색 지우기',
10
10
  search_results: '{n}개 결과',
@@ -26,7 +26,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
26
26
  open_in_chat: '채팅에서 열기', today: '오늘', day_ago: '1일 전', days_ago: '{n}일 전', months_ago: '{n}개월 전', years_ago: '{n}년 전',
27
27
  },
28
28
  en: {
29
- nav_home: 'Home', nav_graph: 'Knowledge Graph', nav_chat: 'Chat', nav_files: 'Files', nav_code: 'Code', nav_settings: 'Settings',
29
+ nav_home: 'Home', nav_workspace: 'Workspace OS', nav_graph: 'Knowledge Graph', nav_chat: 'Chat', nav_files: 'Files', nav_code: 'Code', nav_settings: 'Settings',
30
30
  project: 'Project', search_title: 'Explore the graph', search_sub: 'Search topics, files, conversations, decisions, and tasks.',
31
31
  ready: 'Ready', search_ph: 'Search by topic, file, or conversation...', clear_search: 'Clear search',
32
32
  search_results: '{n} result(s)',
@@ -54,7 +54,7 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
54
54
 
55
55
  function applyI18n() {
56
56
  document.documentElement.lang = currentLang;
57
- const navLabels = ['nav_home', 'nav_graph', 'nav_chat', 'nav_files', 'nav_code', 'nav_settings'];
57
+ const navLabels = ['nav_home', 'nav_workspace', 'nav_graph', 'nav_chat', 'nav_files', 'nav_code', 'nav_settings'];
58
58
  document.querySelectorAll('.graph-rail nav a').forEach((link, index) => {
59
59
  const icon = link.querySelector('i')?.outerHTML || '';
60
60
  link.innerHTML = `${icon} ${t(navLabels[index])}`;
@@ -0,0 +1,382 @@
1
+ const API_BASE = window.location.protocol === "file:" ? "http://localhost:4825" : "";
2
+
3
+ const state = {
4
+ os: null,
5
+ snapshots: [],
6
+ };
7
+
8
+ function $(id) {
9
+ return document.getElementById(id);
10
+ }
11
+
12
+ function escapeHtml(value) {
13
+ return String(value ?? "")
14
+ .replaceAll("&", "&amp;")
15
+ .replaceAll("<", "&lt;")
16
+ .replaceAll(">", "&gt;")
17
+ .replaceAll('"', "&quot;")
18
+ .replaceAll("'", "&#039;");
19
+ }
20
+
21
+ async function api(path, options = {}) {
22
+ const headers = { ...(options.headers || {}) };
23
+ if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
24
+ const response = await fetch(API_BASE + path, { credentials: "include", ...options, headers });
25
+ const text = await response.text();
26
+ let data = {};
27
+ try { data = text ? JSON.parse(text) : {}; } catch { data = { detail: text }; }
28
+ if (!response.ok) {
29
+ throw new Error(data.detail || `${response.status} ${response.statusText}`);
30
+ }
31
+ return data;
32
+ }
33
+
34
+ function toast(message) {
35
+ const node = $("toast");
36
+ node.textContent = message;
37
+ node.classList.add("show");
38
+ clearTimeout(node._timer);
39
+ node._timer = setTimeout(() => node.classList.remove("show"), 2200);
40
+ }
41
+
42
+ function renderMetrics(os) {
43
+ const counts = os?.counts || {};
44
+ const graph = os?.graph || {};
45
+ const nodeTotal = Object.values(graph.nodes || {}).reduce((sum, value) => sum + Number(value || 0), 0);
46
+ const edgeTotal = Object.values(graph.edges || {}).reduce((sum, value) => sum + Number(value || 0), 0);
47
+ const items = [
48
+ ["Graph Nodes", nodeTotal, "ti-chart-dots-3"],
49
+ ["Graph Edges", edgeTotal, "ti-git-branch"],
50
+ ["Snapshots", counts.snapshots || 0, "ti-stack-2"],
51
+ ["Memories", counts.memories || 0, "ti-book-2"],
52
+ ];
53
+ $("metric-grid").innerHTML = items.map(([label, value, icon]) => `
54
+ <div class="metric-card">
55
+ <i class="ti ${icon}"></i>
56
+ <span>${escapeHtml(label)}</span>
57
+ <strong>${escapeHtml(value)}</strong>
58
+ </div>
59
+ `).join("");
60
+ }
61
+
62
+ function renderOnboarding(payload) {
63
+ const steps = payload.steps || [];
64
+ $("onboarding-steps").innerHTML = steps.map((step) => {
65
+ const status = step.status || "pending";
66
+ const label = step.id.replaceAll("_", " ");
67
+ return `
68
+ <button class="step-chip" data-step="${escapeHtml(step.id)}" title="Mark ${escapeHtml(label)} complete">
69
+ <span>${escapeHtml(label)}</span>
70
+ <span class="status-pill status-${escapeHtml(status)}">${escapeHtml(status)}</span>
71
+ </button>
72
+ `;
73
+ }).join("");
74
+ }
75
+
76
+ function renderTraces(payload) {
77
+ const traces = payload.traces || [];
78
+ $("trace-list").innerHTML = traces.length ? traces.map((trace) => `
79
+ <div class="list-item">
80
+ <div class="list-title">
81
+ <span>${escapeHtml(trace.question || "Trace")}</span>
82
+ <span class="status-pill">${Math.round((trace.confidence || 0) * 100)}%</span>
83
+ </div>
84
+ <div class="meta-line">${escapeHtml(trace.created_at || "")} · ${escapeHtml(trace.conversation_id || "workspace")}</div>
85
+ <div class="tag-row">
86
+ ${(trace.graph_nodes || []).slice(0, 5).map((node) => `<a class="tag" href="/graph?node=${encodeURIComponent(node.id)}">${escapeHtml(node.title || node.id)}</a>`).join("")}
87
+ </div>
88
+ <div class="mini-row">${escapeHtml((trace.source_files || []).map((source) => source.source).slice(0, 3).join(" · ") || "No source files")}</div>
89
+ </div>
90
+ `).join("") : `<div class="list-item"><div class="meta-line">No answer traces yet.</div></div>`;
91
+ }
92
+
93
+ function renderIndexing(payload) {
94
+ const sources = payload.sources || [];
95
+ $("indexing-list").innerHTML = sources.length ? sources.map((source) => `
96
+ <div class="list-item">
97
+ <div class="list-title">
98
+ <span>${escapeHtml(source.label || source.root_path)}</span>
99
+ <span class="status-pill ${source.watch_active ? "status-complete" : ""}">${source.watch_active ? "watching" : source.status || "idle"}</span>
100
+ </div>
101
+ <div class="meta-line">${escapeHtml(source.root_path || "")}</div>
102
+ <div class="tag-row">
103
+ <span class="tag">${Number(source.success_count || 0)} indexed</span>
104
+ <span class="tag">${Number(source.failure_count || 0)} failed</span>
105
+ <span class="tag">${escapeHtml(source.last_run_at || "not scanned")}</span>
106
+ </div>
107
+ <div class="item-actions">
108
+ <button class="small-action" data-index-action="resume" data-source="${escapeHtml(source.id)}"><i class="ti ti-player-play"></i>Resume</button>
109
+ <button class="small-action" data-index-action="pause" data-source="${escapeHtml(source.id)}"><i class="ti ti-player-pause"></i>Pause</button>
110
+ <button class="small-action danger-action" data-index-action="remove" data-source="${escapeHtml(source.id)}"><i class="ti ti-trash"></i>Remove</button>
111
+ </div>
112
+ </div>
113
+ `).join("") : `<div class="list-item"><div class="meta-line">No indexed folders.</div></div>`;
114
+ }
115
+
116
+ function renderSnapshots(payload) {
117
+ const snapshots = payload.snapshots || [];
118
+ state.snapshots = snapshots;
119
+ $("snapshot-list").innerHTML = snapshots.length ? snapshots.map((snapshot) => `
120
+ <div class="list-item">
121
+ <div class="list-title">
122
+ <span>${escapeHtml(snapshot.name)}</span>
123
+ <span class="status-pill">${escapeHtml(snapshot.node_count || 0)} nodes</span>
124
+ </div>
125
+ <div class="meta-line">${escapeHtml(snapshot.created_at)} · ${escapeHtml(snapshot.id)}</div>
126
+ <div class="item-actions">
127
+ <button class="small-action" data-export-snapshot="${escapeHtml(snapshot.id)}"><i class="ti ti-package-export"></i>Export</button>
128
+ </div>
129
+ </div>
130
+ `).join("") : `<div class="list-item"><div class="meta-line">No snapshots.</div></div>`;
131
+
132
+ const options = snapshots.map((snapshot) => `<option value="${escapeHtml(snapshot.id)}">${escapeHtml(snapshot.name)}</option>`).join("");
133
+ $("snapshot-before").innerHTML = options;
134
+ $("snapshot-after").innerHTML = options;
135
+ if (snapshots[1]) $("snapshot-before").value = snapshots[1].id;
136
+ if (snapshots[0]) $("snapshot-after").value = snapshots[0].id;
137
+ }
138
+
139
+ function renderMemories(payload) {
140
+ const memories = payload.memories || [];
141
+ $("memory-list").innerHTML = memories.length ? memories.map((memory) => `
142
+ <div class="list-item">
143
+ <div class="list-title">
144
+ <span>${escapeHtml(memory.kind || "memory")}</span>
145
+ <span class="status-pill">${escapeHtml(memory.updated_at || "")}</span>
146
+ </div>
147
+ <div>${escapeHtml(memory.content || "")}</div>
148
+ <div class="tag-row">${(memory.tags || []).map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("")}</div>
149
+ </div>
150
+ `).join("") : `<div class="list-item"><div class="meta-line">No personal memory yet.</div></div>`;
151
+ }
152
+
153
+ function renderComputerMemory(payload) {
154
+ const config = payload?.computer_memory || payload || {};
155
+ $("computer-memory-toggle").checked = Boolean(config.enabled);
156
+ $("computer-memory-state").textContent = JSON.stringify({
157
+ enabled: Boolean(config.enabled),
158
+ approved: Boolean(config.approved),
159
+ scopes: config.scopes || [],
160
+ activities: (config.activities || []).length,
161
+ notice: config.notice,
162
+ }, null, 2);
163
+ }
164
+
165
+ function renderAgents(payload) {
166
+ const agents = payload.agents || [];
167
+ $("agent-list").innerHTML = agents.map((agent) => `
168
+ <div class="list-item">
169
+ <div class="list-title">
170
+ <span>${escapeHtml(agent.name)}</span>
171
+ <span class="status-pill status-complete">${escapeHtml(agent.status || "available")}</span>
172
+ </div>
173
+ <div class="meta-line">${escapeHtml(agent.role || "")}</div>
174
+ <div class="tag-row">${(agent.relationships || []).map((rel) => `<span class="tag">${escapeHtml(rel)}</span>`).join("")}</div>
175
+ </div>
176
+ `).join("");
177
+ }
178
+
179
+ function renderWorkflows(payload) {
180
+ const workflows = payload.workflows || [];
181
+ $("workflow-list").innerHTML = workflows.length ? workflows.map((workflow) => `
182
+ <div class="list-item">
183
+ <div class="list-title">
184
+ <span>${escapeHtml(workflow.name)}</span>
185
+ <span class="status-pill">${(workflow.steps || []).length} steps</span>
186
+ </div>
187
+ <div class="meta-line">${escapeHtml(workflow.created_at || "")}</div>
188
+ <div class="tag-row">${(workflow.steps || []).slice(0, 4).map((step) => `<span class="tag">${escapeHtml(step.action || step.name || "step")}</span>`).join("")}</div>
189
+ </div>
190
+ `).join("") : `<div class="list-item"><div class="meta-line">No workflows.</div></div>`;
191
+ }
192
+
193
+ function renderSkills(payload) {
194
+ const installed = payload.installed || [];
195
+ const available = (payload.available || []).filter((skill) => !installed.some((item) => item.name === (skill.skill || skill.name))).slice(0, 8);
196
+ const rows = [
197
+ ...installed.map((skill) => ({ ...skill, marketplace: false })),
198
+ ...available.map((skill) => ({ name: skill.skill || skill.name, description: skill.description, version: skill.version || "remote", enabled: skill.enabled, marketplace: true })),
199
+ ];
200
+ $("skill-list").innerHTML = rows.length ? rows.map((skill) => `
201
+ <div class="list-item">
202
+ <div class="list-title">
203
+ <span>${escapeHtml(skill.name)}</span>
204
+ <span class="status-pill ${skill.enabled === false ? "status-failed" : "status-complete"}">${skill.enabled === false ? "disabled" : "enabled"}</span>
205
+ </div>
206
+ <div class="meta-line">${escapeHtml(skill.description || "")}</div>
207
+ <div class="tag-row">
208
+ <span class="tag">${escapeHtml(skill.version || "local")}</span>
209
+ <span class="tag">${skill.marketplace ? "marketplace" : "installed"}</span>
210
+ </div>
211
+ <div class="item-actions">
212
+ <button class="small-action" data-skill-action="enable" data-skill="${escapeHtml(skill.name)}"><i class="ti ti-toggle-right"></i>Enable</button>
213
+ <button class="small-action" data-skill-action="disable" data-skill="${escapeHtml(skill.name)}"><i class="ti ti-toggle-left"></i>Disable</button>
214
+ </div>
215
+ </div>
216
+ `).join("") : `<div class="list-item"><div class="meta-line">No skills found.</div></div>`;
217
+ }
218
+
219
+ function renderTimeline(payload) {
220
+ const events = payload.events || [];
221
+ $("timeline-list").innerHTML = events.length ? events.slice(0, 40).map((event) => `
222
+ <div class="timeline-item">
223
+ <div class="list-title"><span>${escapeHtml(event.event_type || "event")}</span><span class="status-pill">${escapeHtml(event.area || "workspace")}</span></div>
224
+ <div class="meta-line">${escapeHtml(event.timestamp || "")}</div>
225
+ </div>
226
+ `).join("") : `<div class="timeline-item"><div class="meta-line">No timeline events.</div></div>`;
227
+ }
228
+
229
+ async function refreshAll() {
230
+ const [os, onboarding, traces, indexing, snapshots, memories, computerMemory, agents, workflows, skills, timeline] = await Promise.all([
231
+ api("/workspace/os"),
232
+ api("/workspace/onboarding/status"),
233
+ api("/workspace/traces"),
234
+ api("/workspace/indexing"),
235
+ api("/workspace/snapshots"),
236
+ api("/workspace/memories"),
237
+ api("/workspace/computer-memory"),
238
+ api("/workspace/agents"),
239
+ api("/workspace/workflows"),
240
+ api("/workspace/skills"),
241
+ api("/workspace/time-machine"),
242
+ ]);
243
+ state.os = os;
244
+ renderMetrics(os);
245
+ renderOnboarding(onboarding);
246
+ renderTraces(traces);
247
+ renderIndexing(indexing);
248
+ renderSnapshots(snapshots);
249
+ renderMemories(memories);
250
+ renderComputerMemory(computerMemory);
251
+ renderAgents(agents);
252
+ renderWorkflows(workflows);
253
+ renderSkills(skills);
254
+ renderTimeline(timeline);
255
+ }
256
+
257
+ async function createSnapshot() {
258
+ const name = $("snapshot-name").value || "Workspace snapshot";
259
+ const payload = await api("/workspace/snapshots", {
260
+ method: "POST",
261
+ body: JSON.stringify({ name }),
262
+ });
263
+ toast(`Snapshot saved: ${payload.snapshot.id}`);
264
+ await refreshAll();
265
+ }
266
+
267
+ async function compareSnapshots() {
268
+ const beforeId = $("snapshot-before").value;
269
+ const afterId = $("snapshot-after").value;
270
+ if (!beforeId || !afterId) return;
271
+ const diff = await api("/workspace/snapshots/compare", {
272
+ method: "POST",
273
+ body: JSON.stringify({ before_id: beforeId, after_id: afterId }),
274
+ });
275
+ $("snapshot-diff").textContent = JSON.stringify(diff.summary, null, 2);
276
+ }
277
+
278
+ async function saveMemory() {
279
+ const content = $("memory-content").value.trim();
280
+ if (!content) return;
281
+ await api("/workspace/memories", {
282
+ method: "POST",
283
+ body: JSON.stringify({
284
+ kind: $("memory-kind").value,
285
+ content,
286
+ tags: [],
287
+ }),
288
+ });
289
+ $("memory-content").value = "";
290
+ toast("Memory saved");
291
+ await refreshAll();
292
+ }
293
+
294
+ async function createDemoWorkflow() {
295
+ await api("/workspace/workflows", {
296
+ method: "POST",
297
+ body: JSON.stringify({
298
+ name: "Upload -> Summarize -> Generate -> Export",
299
+ steps: [
300
+ { action: "upload" },
301
+ { action: "summarize" },
302
+ { action: "generate" },
303
+ { action: "export" },
304
+ ],
305
+ }),
306
+ });
307
+ toast("Workflow created");
308
+ await refreshAll();
309
+ }
310
+
311
+ async function configureComputerMemory(enabled) {
312
+ const consent = enabled
313
+ ? { approved: true, reason: "Enabled from Workspace OS UI", approved_at: new Date().toISOString() }
314
+ : { approved: false };
315
+ await api("/workspace/computer-memory", {
316
+ method: "POST",
317
+ body: JSON.stringify({ enabled, consent }),
318
+ });
319
+ toast(enabled ? "Computer Memory enabled" : "Computer Memory disabled");
320
+ await refreshAll();
321
+ }
322
+
323
+ document.addEventListener("click", async (event) => {
324
+ const step = event.target.closest("[data-step]");
325
+ if (step) {
326
+ await api("/workspace/onboarding/step", {
327
+ method: "POST",
328
+ body: JSON.stringify({ step: step.dataset.step, status: "complete" }),
329
+ });
330
+ toast("Onboarding step saved");
331
+ await refreshAll();
332
+ return;
333
+ }
334
+
335
+ const indexBtn = event.target.closest("[data-index-action]");
336
+ if (indexBtn) {
337
+ const action = indexBtn.dataset.indexAction;
338
+ const source = indexBtn.dataset.source;
339
+ await api(`/workspace/indexing/${encodeURIComponent(source)}/${action}`, { method: "POST" });
340
+ toast(`Index ${action} complete`);
341
+ await refreshAll();
342
+ return;
343
+ }
344
+
345
+ const exportBtn = event.target.closest("[data-export-snapshot]");
346
+ if (exportBtn) {
347
+ const result = await api(`/workspace/snapshots/${encodeURIComponent(exportBtn.dataset.exportSnapshot)}/export`, { method: "POST" });
348
+ toast(`Exported ${result.bytes} bytes`);
349
+ return;
350
+ }
351
+
352
+ const skillBtn = event.target.closest("[data-skill-action]");
353
+ if (skillBtn) {
354
+ await api(`/workspace/skills/${skillBtn.dataset.skillAction}`, {
355
+ method: "POST",
356
+ body: JSON.stringify({ skill: skillBtn.dataset.skill }),
357
+ });
358
+ toast(`Skill ${skillBtn.dataset.skillAction}`);
359
+ await refreshAll();
360
+ }
361
+ });
362
+
363
+ document.addEventListener("DOMContentLoaded", () => {
364
+ $("refresh-btn").addEventListener("click", () => refreshAll().catch((err) => toast(err.message)));
365
+ $("snapshot-now").addEventListener("click", () => createSnapshot().catch((err) => toast(err.message)));
366
+ $("create-snapshot").addEventListener("click", () => createSnapshot().catch((err) => toast(err.message)));
367
+ $("complete-onboarding").addEventListener("click", async () => {
368
+ await api("/workspace/onboarding/complete", { method: "POST", body: JSON.stringify({ data: { ui: "workspace" } }) });
369
+ toast("Onboarding complete");
370
+ await refreshAll();
371
+ });
372
+ $("reload-traces").addEventListener("click", () => refreshAll().catch((err) => toast(err.message)));
373
+ $("compare-snapshots").addEventListener("click", () => compareSnapshots().catch((err) => toast(err.message)));
374
+ $("save-memory").addEventListener("click", () => saveMemory().catch((err) => toast(err.message)));
375
+ $("computer-memory-toggle").addEventListener("change", (event) => configureComputerMemory(event.target.checked).catch((err) => {
376
+ event.target.checked = false;
377
+ toast(err.message);
378
+ }));
379
+ $("create-demo-workflow").addEventListener("click", () => createDemoWorkflow().catch((err) => toast(err.message)));
380
+ $("reload-skills").addEventListener("click", () => refreshAll().catch((err) => toast(err.message)));
381
+ refreshAll().catch((err) => toast(err.message));
382
+ });
package/static/sw.js CHANGED
@@ -1,13 +1,16 @@
1
1
  // Lattice AI Service Worker — enables PWA install on Android/iOS
2
2
  // Strategy: network-first for API, cache-first for static assets.
3
- const CACHE = "ltcai-v7";
3
+ const CACHE = "ltcai-v100";
4
4
  const STATIC = [
5
5
  "/",
6
+ "/workspace",
6
7
  "/static/lattice-reference.css",
8
+ "/static/workspace.css",
7
9
  "/static/css/tokens.css",
8
10
  "/static/scripts/chat.js",
9
11
  "/static/scripts/admin.js",
10
12
  "/static/scripts/graph.js",
13
+ "/static/scripts/workspace.js",
11
14
  "/static/scripts/account.js",
12
15
  "/manifest.json",
13
16
  "/icons/icon-192.png",
@@ -45,6 +48,7 @@ self.addEventListener("fetch", e => {
45
48
  url.pathname.startsWith("/local") ||
46
49
  url.pathname.startsWith("/tools") ||
47
50
  url.pathname.startsWith("/knowledge") ||
51
+ url.pathname.startsWith("/workspace/") ||
48
52
  url.pathname.startsWith("/history")) {
49
53
  e.respondWith(fetch(e.request));
50
54
  return;