ltcai 4.6.1 → 4.7.2

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 (34) hide show
  1. package/README.md +74 -40
  2. package/docs/CHANGELOG.md +141 -0
  3. package/docs/PRODUCT_DIRECTION_REVIEW.md +88 -0
  4. package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +42 -0
  5. package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +49 -0
  6. package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +62 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +22 -19
  8. package/frontend/src/App.tsx +627 -8
  9. package/frontend/src/api/client.ts +11 -1
  10. package/frontend/src/components/ProductFlow.tsx +106 -51
  11. package/frontend/src/pages/System.tsx +1 -1
  12. package/frontend/src/styles.css +905 -81
  13. package/lattice_brain/__init__.py +1 -1
  14. package/lattice_brain/archive.py +86 -13
  15. package/lattice_brain/portability.py +82 -14
  16. package/lattice_brain/runtime/multi_agent.py +1 -1
  17. package/latticeai/__init__.py +1 -1
  18. package/latticeai/api/admin.py +141 -6
  19. package/latticeai/api/chat.py +35 -13
  20. package/latticeai/app_factory.py +8 -4
  21. package/latticeai/core/audit.py +3 -2
  22. package/latticeai/core/marketplace.py +1 -1
  23. package/latticeai/core/workspace_os.py +1 -1
  24. package/package.json +2 -1
  25. package/src-tauri/Cargo.lock +1 -1
  26. package/src-tauri/Cargo.toml +1 -1
  27. package/src-tauri/tauri.conf.json +1 -1
  28. package/static/app/asset-manifest.json +5 -5
  29. package/static/app/assets/index-DdAB4yfa.js +16 -0
  30. package/static/app/assets/index-DdAB4yfa.js.map +1 -0
  31. package/static/app/assets/{index-7U86v70r.css → index-KlQ04wVv.css} +1 -1
  32. package/static/app/index.html +2 -2
  33. package/static/app/assets/index-D1jAPQws.js +0 -16
  34. package/static/app/assets/index-D1jAPQws.js.map +0 -1
@@ -12,6 +12,13 @@ export type ApiResult<T = unknown> = {
12
12
 
13
13
  type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
14
14
  type Query = Record<string, string | number | boolean | null | undefined>;
15
+ export type AdminAuditFilters = {
16
+ q?: string;
17
+ actor?: string;
18
+ action?: string;
19
+ severity?: string;
20
+ limit?: number;
21
+ };
15
22
 
16
23
  const TIMEOUT_MS = 10_000;
17
24
  const clients = new Map<string, ReturnType<typeof createClient<paths>>>();
@@ -417,12 +424,15 @@ export const latticeApi = {
417
424
  computerMemory: () => get("/workspace/computer-memory", {}),
418
425
  setComputerMemory: (enabled: boolean) => post("/workspace/computer-memory", { enabled, consent: { approved: enabled } }, {}),
419
426
  adminSummary: () => get("/admin/summary", {}),
427
+ adminStats: () => get("/admin/stats", {}),
420
428
  adminUsers: () => get("/admin/users", []),
421
- adminAudit: () => get("/admin/audit", { recent_events: [] }),
429
+ adminAudit: (filters?: AdminAuditFilters) => get("/admin/audit", { recent_events: [], filters: {} }, filters),
422
430
  adminRoles: () => get("/admin/roles", { roles: [] }),
423
431
  adminPolicies: () => get("/admin/policies", { policies: [] }),
432
+ adminLogRetention: () => get("/admin/log-retention", {}),
424
433
  adminProductHardening: () => get("/admin/product-hardening", {}),
425
434
  adminSecurity: () => get("/admin/security/overview", {}),
435
+ adminSecurityEvents: (limit = 50) => get("/admin/security/events", { events: [] }, { limit }),
426
436
  vpcStatus: () => get("/vpc/status", {}),
427
437
  toolPermissions: () => get("/tools/permissions", { permissions: [] }),
428
438
  };
@@ -43,6 +43,14 @@ export function readProductFlowComplete() {
43
43
  return false;
44
44
  }
45
45
 
46
+ function readSavedFlowUser(): { email?: string; name?: string } | null {
47
+ try {
48
+ const raw = localStorage.getItem(FLOW_USER_KEY);
49
+ return raw ? JSON.parse(raw) : null;
50
+ } catch {}
51
+ return null;
52
+ }
53
+
46
54
  export function ProductFlow({ onComplete }: { onComplete: () => void }) {
47
55
  const [step, setStep] = React.useState<FlowStep>("login");
48
56
  const [analysis, setAnalysis] = React.useState<FlowAnalysis | null>(null);
@@ -135,80 +143,119 @@ export function ProductFlow({ onComplete }: { onComplete: () => void }) {
135
143
 
136
144
  function LoginScreen({ onSuccess }: { onSuccess: () => void }) {
137
145
  const [email, setEmail] = React.useState(() => {
138
- try {
139
- const saved = localStorage.getItem(FLOW_USER_KEY);
140
- return saved ? JSON.parse(saved).email || "you@local" : "you@local";
141
- } catch {
142
- return "you@local";
143
- }
146
+ return readSavedFlowUser()?.email || "you@local";
144
147
  });
145
148
  const [password, setPassword] = React.useState("");
146
- const [name, setName] = React.useState("You");
149
+ const [name, setName] = React.useState(() => readSavedFlowUser()?.name || "You");
147
150
  const [busy, setBusy] = React.useState(false);
148
151
  const [error, setError] = React.useState<string | null>(null);
149
152
 
150
153
  async function submit(event: React.FormEvent) {
151
154
  event.preventDefault();
155
+ const cleanEmail = email.trim();
156
+ const cleanPassword = password.trim();
157
+ const cleanName = name.trim() || cleanEmail.split("@")[0] || "You";
158
+ if (!cleanEmail || !cleanPassword) {
159
+ setError("이름과 이메일, 비밀번호를 입력하면 기존 Brain을 안전하게 확인합니다.");
160
+ return;
161
+ }
152
162
  setBusy(true);
153
163
  setError(null);
154
- const safePassword = password || "Lattice123";
155
- let result = await latticeApi.login(email, safePassword);
156
- if (!result.ok) {
157
- const registered = await latticeApi.register({
158
- email,
159
- password: safePassword,
160
- name: name || email.split("@")[0] || "You",
161
- nickname: name || "You",
162
- });
163
- if (registered.ok) result = await latticeApi.login(email, safePassword);
164
- }
164
+ const savedUser = readSavedFlowUser();
165
+ let result = await latticeApi.login(cleanEmail, cleanPassword);
165
166
  if (!result.ok) {
166
167
  const profile = await latticeApi.profile();
167
- if (!profile.ok) {
168
+ if (profile.ok && (!savedUser?.email || savedUser.email === cleanEmail)) {
169
+ try { localStorage.setItem(FLOW_USER_KEY, JSON.stringify({ email: cleanEmail, name: cleanName })); } catch {}
170
+ setBusy(false);
171
+ onSuccess();
172
+ return;
173
+ }
174
+ if (savedUser?.email && savedUser.email !== cleanEmail) {
168
175
  setBusy(false);
169
- setError("We could not open your local profile. Check your password or create the first local account.");
176
+ setError(" 컴퓨터의 기존 Brain과 다른 이메일입니다. 오타인지 확인해 주세요.");
170
177
  return;
171
178
  }
179
+ if (savedUser?.email === cleanEmail) {
180
+ setBusy(false);
181
+ setError("기존 Brain 이메일은 맞지만 비밀번호가 다릅니다. 비밀번호를 다시 확인해 주세요.");
182
+ return;
183
+ }
184
+ const registered = await latticeApi.register({
185
+ email: cleanEmail,
186
+ password: cleanPassword,
187
+ name: cleanName,
188
+ nickname: cleanName,
189
+ });
190
+ if (registered.ok) result = await latticeApi.login(cleanEmail, cleanPassword);
191
+ }
192
+ if (!result.ok) {
193
+ setBusy(false);
194
+ setError("로컬 프로필을 열 수 없습니다. 이메일과 비밀번호를 확인해 주세요.");
195
+ return;
172
196
  }
173
- try { localStorage.setItem(FLOW_USER_KEY, JSON.stringify({ email, name })); } catch {}
197
+ try { localStorage.setItem(FLOW_USER_KEY, JSON.stringify({ email: cleanEmail, name: cleanName })); } catch {}
174
198
  setBusy(false);
175
199
  onSuccess();
176
200
  }
177
201
 
178
202
  return (
179
203
  <div>
180
- <div className="ritual-title">Welcome to your mind.</div>
181
- <div className="ritual-subtitle">This is private. Everything stays on your machine. Begin by opening a local profile for your Brain.</div>
204
+ <div className="ritual-title">내 Brain을 시작합니다.</div>
205
+ <div className="ritual-subtitle">
206
+ 모델은 바뀔 수 있지만, 내 문서와 대화, 결정, 기억은 사라지면 안 됩니다. Lattice는 이 지식을 내가 소유하는 개인 Brain으로 모읍니다.
207
+ </div>
208
+
209
+ <ProductPromise />
182
210
 
183
211
  <form onSubmit={submit} className="ritual-card" style={{ maxWidth: 420, margin: "0 auto" }}>
184
212
  <div style={{ display: "grid", gap: "0.85rem" }}>
185
213
  <div>
186
- <div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>Your name</div>
214
+ <div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>이름</div>
187
215
  <Input value={name} onChange={(e) => setName(e.target.value)} placeholder="You" />
188
216
  </div>
189
217
  <div>
190
- <div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>Email (local only)</div>
218
+ <div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>이메일</div>
191
219
  <Input value={email} onChange={(e) => setEmail(e.target.value)} type="email" placeholder="you@local" />
192
220
  </div>
193
221
  <div>
194
- <div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>Password</div>
195
- <Input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder="Create a strong local password" />
222
+ <div style={{ fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "1px", color: "hsl(var(--fg-muted))", marginBottom: 4 }}>비밀번호</div>
223
+ <Input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder="로컬 Brain 비밀번호" />
196
224
  </div>
197
225
  </div>
198
226
 
199
227
  {error && <div style={{ marginTop: "0.85rem", padding: "0.6rem 0.85rem", background: "hsl(var(--destructive)/0.12)", border: "1px solid hsl(var(--destructive)/0.4)", borderRadius: 10, fontSize: "0.9rem" }}>{error}</div>}
200
228
 
201
- <Button type="submit" disabled={busy || !email.trim()} style={{ width: "100%", marginTop: "1rem" }}>
202
- {busy ? "Opening the Brain..." : "Open my Brain"}
229
+ <Button type="submit" disabled={busy || !email.trim() || !password.trim()} style={{ width: "100%", marginTop: "1rem" }}>
230
+ {busy ? "Brain 여는 중..." : " Brain 시작하기"}
203
231
  </Button>
204
232
  <div style={{ fontSize: "0.75rem", color: "hsl(var(--fg-muted))", marginTop: "0.6rem" }}>
205
- Your first conversation will feel like coming home.
233
+ 기존 Brain과 다른 이메일이면 새로 만들지 않고 먼저 확인합니다.
206
234
  </div>
207
235
  </form>
208
236
  </div>
209
237
  );
210
238
  }
211
239
 
240
+ function ProductPromise() {
241
+ return (
242
+ <div className="ritual-promise" aria-label="Lattice AI product promise">
243
+ <div>
244
+ <span>오래 남는 지식</span>
245
+ <strong>내 일이 장기 기억이 됩니다.</strong>
246
+ </div>
247
+ <div>
248
+ <span>교체 가능한 모델</span>
249
+ <strong>모델은 목소리이고, 자산은 Brain입니다.</strong>
250
+ </div>
251
+ <div>
252
+ <span>사용자 소유</span>
253
+ <strong>백업, 복원, 이동이 가능합니다.</strong>
254
+ </div>
255
+ </div>
256
+ );
257
+ }
258
+
212
259
  function AnalysisScreen({
213
260
  analysis,
214
261
  error,
@@ -221,9 +268,9 @@ function AnalysisScreen({
221
268
  const detected = buildDetectedFacts(analysis);
222
269
  return (
223
270
  <div>
224
- <div className="ritual-title">Understanding your home.</div>
271
+ <div className="ritual-title">이 컴퓨터를 확인합니다.</div>
225
272
  <div className="ritual-subtitle">
226
- We are learning what kind of mind this computer can support. Your Brain will live here — quietly, privately, powerfully.
273
+ Brain이 클라우드에 의존하지 않고 컴퓨터에서 편하게 돌아갈 있는지 확인합니다.
227
274
  </div>
228
275
 
229
276
  <div className="ritual-fact-grid">
@@ -240,9 +287,9 @@ function AnalysisScreen({
240
287
  <div style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
241
288
  <Sparkles style={{ color: "hsl(var(--brain-core))" }} />
242
289
  <div>
243
- <div style={{ fontWeight: 620 }}>{analysis ? recommendedSummary(analysis) : "Preparing the best fit..."}</div>
290
+ <div style={{ fontWeight: 620 }}>{analysis ? recommendedSummary(analysis) : "가장 편한 설정을 찾는 중..."}</div>
244
291
  <div style={{ fontSize: "0.9rem", color: "hsl(var(--fg-muted))" }}>
245
- {analysis ? "A short, personal list of minds is ready for you to choose from." : "Reading your machine. This is gentle."}
292
+ {analysis ? "추천 모델을 바로 시작할 있게 준비했습니다." : "잠시만 기다리면 자동으로 정리됩니다."}
246
293
  </div>
247
294
  </div>
248
295
  </div>
@@ -252,7 +299,7 @@ function AnalysisScreen({
252
299
 
253
300
  <div style={{ marginTop: "1.25rem" }}>
254
301
  <Button onClick={onContinue} disabled={!analysis && !error} style={{ minWidth: 260 }}>
255
- See how your Brain can think
302
+ 추천 모델 보기
256
303
  </Button>
257
304
  </div>
258
305
  </div>
@@ -271,12 +318,17 @@ function RecommendationScreen({
271
318
  const items = recommendations.length ? recommendations : [fallbackModel()];
272
319
  return (
273
320
  <div>
274
- <div className="ritual-title">How shall your mind think today?</div>
321
+ <div className="ritual-title">추천 모델로 시작하세요.</div>
275
322
  <div className="ritual-subtitle">
276
- A short, honest list chosen for the computer you are on right now. Pick the one that feels right.
323
+ 모델은 Brain의 현재 목소리입니다. 나중에 바꿔도 기억과 지식은 그대로 남습니다.
277
324
  </div>
278
325
 
279
326
  <div style={{ maxWidth: 560, margin: "0 auto" }}>
327
+ {items[0]?.supported ? (
328
+ <Button onClick={() => onSelect(items[0])} style={{ width: "100%", marginBottom: "0.85rem" }}>
329
+ 추천대로 시작하기
330
+ </Button>
331
+ ) : null}
280
332
  {items.slice(0, 3).map((model, index) => (
281
333
  <button
282
334
  key={`${model.role}-${model.id}`}
@@ -288,14 +340,14 @@ function RecommendationScreen({
288
340
  <div className="role">{rankLabel(model.role, index)}</div>
289
341
  <div className="name">{model.shortName}</div>
290
342
  <div className="reason">{model.reason} · {model.size || "ready"}</div>
291
- {!model.supported && <div style={{ color: "hsl(var(--destructive))", marginTop: 6, fontSize: "0.85rem" }}>Needs attention on this machine</div>}
343
+ {!model.supported && <div style={{ color: "hsl(var(--destructive))", marginTop: 6, fontSize: "0.85rem" }}>이 컴퓨터에서 추가 확인이 필요합니다</div>}
292
344
  </button>
293
345
  ))}
294
346
  </div>
295
347
 
296
348
  <div style={{ marginTop: "1.1rem", display: "flex", justifyContent: "center", gap: "1rem", alignItems: "center" }}>
297
- <Button variant="ghost" onClick={onBack}>Back</Button>
298
- <div style={{ fontSize: "0.82rem", color: "hsl(var(--fg-muted))" }}>Your choice becomes the current voice of your Brain.</div>
349
+ <Button variant="ghost" onClick={onBack}>뒤로</Button>
350
+ <div style={{ fontSize: "0.82rem", color: "hsl(var(--fg-muted))" }}>잘 모르겠다면 추천대로 시작하면 됩니다.</div>
299
351
  </div>
300
352
  </div>
301
353
  );
@@ -313,7 +365,7 @@ function InstallScreen({
313
365
  const [busy, setBusy] = React.useState(false);
314
366
  const [stage, setStage] = React.useState<InstallStage>("idle");
315
367
  const [percent, setPercent] = React.useState(0);
316
- const [message, setMessage] = React.useState("Your Brain is waiting for this mind.");
368
+ const [message, setMessage] = React.useState("Brain 사용할 모델을 기다리고 있습니다.");
317
369
  const [error, setError] = React.useState<string | null>(null);
318
370
 
319
371
  async function start() {
@@ -321,7 +373,7 @@ function InstallScreen({
321
373
  setError(null);
322
374
  setStage("install");
323
375
  setPercent(8);
324
- setMessage("Preparing the Brain.");
376
+ setMessage("Brain 준비 중입니다.");
325
377
  const result = await latticeApi.streamModelPrepare(
326
378
  { model: model.loadId, engine: model.engine || "local_mlx", allow_download: true },
327
379
  {
@@ -334,7 +386,7 @@ function InstallScreen({
334
386
  onDone: () => {
335
387
  setStage("done");
336
388
  setPercent(100);
337
- setMessage("Your Brain is ready.");
389
+ setMessage("Brain 준비되었습니다.");
338
390
  },
339
391
  onError: (event) => {
340
392
  setStage("error");
@@ -346,7 +398,7 @@ function InstallScreen({
346
398
  if (result.ok) {
347
399
  setStage("done");
348
400
  setPercent(100);
349
- setMessage("Your Brain is ready.");
401
+ setMessage("Brain 준비되었습니다.");
350
402
  window.setTimeout(onComplete, 700);
351
403
  } else {
352
404
  setStage("error");
@@ -362,10 +414,10 @@ function InstallScreen({
362
414
 
363
415
  return (
364
416
  <div>
365
- <div className="ritual-title">Bring this mind home.</div>
417
+ <div className="ritual-title">모델을 설치하고 시작합니다.</div>
366
418
  <div className="ritual-subtitle">
367
419
  <strong>{model.shortName}</strong> — {model.reason}.<br />
368
- We will download (if needed), validate, and load it. Nothing happens without your explicit consent.
420
+ 모델이 Brain의 로컬 목소리가 됩니다. 다운로드, 확인, 로드는 사용자가 누른 뒤에만 진행됩니다.
369
421
  </div>
370
422
 
371
423
  {/* Living Brain reacts to the ceremony of installation */}
@@ -393,31 +445,34 @@ function InstallScreen({
393
445
  </div>
394
446
 
395
447
  <div className="ritual-status">{message}</div>
448
+ <div className="ritual-card" style={{ margin: "0.8rem auto 0", maxWidth: 540, fontSize: "0.86rem", color: "hsl(var(--fg-muted))" }}>
449
+ 큰 모델은 다운로드에 몇 분 이상 걸릴 수 있습니다. 진행률은 모델 런타임이 알려주는 만큼 표시하고, 멈춘 것처럼 보여도 현재 단계가 유지됩니다.
450
+ </div>
396
451
 
397
452
  {error && (
398
453
  <div className="ritual-card" style={{ borderColor: "hsl(var(--destructive)/0.45)", background: "hsl(var(--destructive)/0.07)", marginBottom: "1rem" }}>
399
454
  {error}
400
- <div style={{ marginTop: "0.5rem", fontSize: "0.85rem" }}>You can go back and choose a different mind, or try again.</div>
455
+ <div style={{ marginTop: "0.5rem", fontSize: "0.85rem" }}>다른 모델을 고르거나 다시 시도할 있습니다.</div>
401
456
  </div>
402
457
  )}
403
458
 
404
459
  <div style={{ display: "flex", gap: "0.75rem", justifyContent: "center", marginTop: "1rem" }}>
405
- <Button variant="ghost" onClick={onBack} disabled={busy}>Choose differently</Button>
460
+ <Button variant="ghost" onClick={onBack} disabled={busy}>다른 모델 고르기</Button>
406
461
 
407
462
  {stage !== "done" ? (
408
463
  <Button
409
464
  onClick={start}
410
465
  disabled={busy || !model.supported}
411
466
  >
412
- {busy ? "Waking the mind..." : "Yes — make this my Brain"}
467
+ {busy ? "모델 준비 중..." : "다운로드하고 시작하기"}
413
468
  </Button>
414
469
  ) : (
415
- <Button onClick={onComplete}>Enter your Brain</Button>
470
+ <Button onClick={onComplete}>Brain으로 들어가기</Button>
416
471
  )}
417
472
  </div>
418
473
 
419
474
  <div style={{ fontSize: "0.72rem", color: "hsl(var(--fg-muted))", marginTop: "0.9rem" }}>
420
- Explicit consent only. All work happens locally on your machine.
475
+ 모든 작업은 컴퓨터에서 진행됩니다.
421
476
  </div>
422
477
  </div>
423
478
  );
@@ -590,7 +590,7 @@ function AdminPanel() {
590
590
  const mode = useAppStore((state) => state.mode);
591
591
  const summary = useQuery({ queryKey: ["adminSummary"], queryFn: latticeApi.adminSummary });
592
592
  const users = useQuery({ queryKey: ["adminUsers"], queryFn: latticeApi.adminUsers });
593
- const audit = useQuery({ queryKey: ["adminAudit"], queryFn: latticeApi.adminAudit });
593
+ const audit = useQuery({ queryKey: ["adminAudit"], queryFn: () => latticeApi.adminAudit() });
594
594
  const roles = useQuery({ queryKey: ["adminRoles"], queryFn: latticeApi.adminRoles });
595
595
  const policies = useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies });
596
596
  const hardening = useQuery({ queryKey: ["adminProductHardening"], queryFn: latticeApi.adminProductHardening });