ltcai 5.2.0 → 5.4.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 +144 -164
- package/docs/CHANGELOG.md +63 -0
- package/docs/DEVELOPMENT.md +99 -0
- package/docs/LEGACY_COMPATIBILITY.md +55 -0
- package/docs/WHY_LATTICE.md +4 -3
- package/frontend/src/App.tsx +8 -2
- package/frontend/src/api/client.ts +2 -0
- package/frontend/src/components/FirstRunGuide.tsx +5 -5
- package/frontend/src/components/ProductFlow.tsx +1 -1
- package/frontend/src/i18n.ts +40 -40
- package/frontend/src/pages/Act.tsx +82 -1
- package/frontend/src/pages/Library.tsx +18 -6
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +12 -0
- package/lattice_brain/portability.py +14 -0
- package/lattice_brain/runtime/__init__.py +53 -0
- package/lattice_brain/runtime/agent_runtime.py +7 -0
- package/lattice_brain/runtime/hooks.py +6 -0
- package/lattice_brain/runtime/multi_agent.py +7 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/workflow_designer.py +60 -0
- package/latticeai/app_factory.py +5 -78
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/__init__.py +2 -0
- package/latticeai/runtime/brain_runtime.py +41 -0
- package/latticeai/runtime/config_runtime.py +36 -0
- package/latticeai/runtime/security_runtime.py +27 -0
- package/latticeai/services/brain_automation.py +214 -0
- package/latticeai/services/model_capability_registry.py +2 -3
- package/latticeai/services/triggers.py +61 -4
- package/package.json +2 -2
- package/scripts/verify_hf_model_registry.py +1 -3
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/index-C7vzwUjU.js +16 -0
- package/static/app/assets/index-C7vzwUjU.js.map +1 -0
- package/static/app/assets/index-HN4f2wbe.css +2 -0
- package/static/app/index.html +2 -2
- package/static/app/assets/index-CQmHhk8Q.css +0 -2
- package/static/app/assets/index-DsnfomFs.js +0 -16
- package/static/app/assets/index-DsnfomFs.js.map +0 -1
package/docs/WHY_LATTICE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Why Lattice AI Exists
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Lattice AI is a local-first Digital Brain that keeps your knowledge durable across any AI model.**
|
|
4
4
|
|
|
5
5
|
**모델은 바꿔도, 내 지식은 남는 로컬 AI 브레인.**
|
|
6
6
|
|
|
@@ -26,7 +26,9 @@ Lattice AI is a local-first private AI memory layer. It keeps conversations,
|
|
|
26
26
|
documents, decisions, relationships, and project history in a Brain that belongs
|
|
27
27
|
to the user. Models can be local, cloud, current, or future. The Brain remains.
|
|
28
28
|
|
|
29
|
-
The graph is real, but it is not the product identity.
|
|
29
|
+
The graph is real, but it is not the product identity. Product category is
|
|
30
|
+
local-first Digital Brain; core capability is a private AI memory layer; UX
|
|
31
|
+
metaphor is the Living Brain. Users start with Brain
|
|
30
32
|
Chat, memory, topics, relationships, ownership, backup, and graph exploration.
|
|
31
33
|
Advanced admin logs, roles, hooks, workflows, Telegram, Brain Network, Docker,
|
|
32
34
|
Postgres, and plugin details stay outside the normal user flow.
|
|
@@ -51,4 +53,3 @@ Postgres, and plugin details stay outside the normal user flow.
|
|
|
51
53
|
- Not a ChatGPT or Claude clone.
|
|
52
54
|
|
|
53
55
|
Lattice AI is for people who want their knowledge to survive model changes.
|
|
54
|
-
|
package/frontend/src/App.tsx
CHANGED
|
@@ -24,6 +24,8 @@ import { ProductFlow, readProductFlowComplete } from "@/components/ProductFlow";
|
|
|
24
24
|
import { useAppStore } from "@/store/appStore";
|
|
25
25
|
import { asArray } from "@/lib/utils";
|
|
26
26
|
import { LANGUAGE_LABELS, t, type Language } from "@/i18n";
|
|
27
|
+
import { parseHash } from "@/routes";
|
|
28
|
+
import { ActPage } from "@/pages/Act";
|
|
27
29
|
|
|
28
30
|
type ApiRecord = Record<string, unknown>;
|
|
29
31
|
type BrainDepth = 1 | 2 | 3 | 4 | 5;
|
|
@@ -73,7 +75,8 @@ export default function App() {
|
|
|
73
75
|
const theme = useAppStore((state) => state.theme);
|
|
74
76
|
const language = useAppStore((state) => state.language);
|
|
75
77
|
const [flowComplete, setFlowComplete] = React.useState(readProductFlowComplete);
|
|
76
|
-
const
|
|
78
|
+
const rawRoute = useHashRoute();
|
|
79
|
+
const parsed = React.useMemo(() => parseHash(), [rawRoute]);
|
|
77
80
|
const { state: brainState, intensity, setBrain } = useBrainState();
|
|
78
81
|
|
|
79
82
|
React.useEffect(() => {
|
|
@@ -99,8 +102,10 @@ export default function App() {
|
|
|
99
102
|
return (
|
|
100
103
|
<div className="brain-space">
|
|
101
104
|
<div className="brain-field" />
|
|
102
|
-
{
|
|
105
|
+
{rawRoute.startsWith("/admin") ? (
|
|
103
106
|
<AdminConsole onBack={() => navigateHash("/brain")} />
|
|
107
|
+
) : parsed.primary === "act" ? (
|
|
108
|
+
<ActPage initialTab={parsed.tab} />
|
|
104
109
|
) : (
|
|
105
110
|
<BrainHome brainState={brainState} intensity={intensity} onBrainChange={setBrain} />
|
|
106
111
|
)}
|
|
@@ -576,6 +581,7 @@ function AdminConsole({ onBack }: { onBack: () => void }) {
|
|
|
576
581
|
<span>{stringValue(retention.retained_events, "0")} retained · {stringValue(retention.prune_candidates, "0")} ready for export/prune review</span>
|
|
577
582
|
</div>
|
|
578
583
|
</AdminPanel>
|
|
584
|
+
|
|
579
585
|
</section>
|
|
580
586
|
</main>
|
|
581
587
|
);
|
|
@@ -368,6 +368,8 @@ export const latticeApi = {
|
|
|
368
368
|
workflowDefinitions: () => get("/workflows/api/definitions", { workflows: [] }),
|
|
369
369
|
workflowRuns: () => get("/workflows/api/runs", { runs: [] }),
|
|
370
370
|
workflowTriggers: () => get("/workflows/api/triggers", { armed: [] }),
|
|
371
|
+
automationRecipes: () => get("/workflows/api/automation/recipes", { recipes: [], principles: {} }),
|
|
372
|
+
installAutomationRecipe: (recipeId: string, enabled = false) => post(`/workflows/api/automation/recipes/${encodeURIComponent(recipeId)}`, { enabled }, {}),
|
|
371
373
|
createWorkflow: (body: { name: string; nodes: Array<Record<string, unknown>>; metadata?: Record<string, unknown> }) => post("/workflows/api/definitions", body, {}),
|
|
372
374
|
importWorkflow: (data: Record<string, unknown>) => post("/workflows/api/import", { data }, {}),
|
|
373
375
|
exportWorkflow: (id: string) => get(`/workflows/api/export/${encodeURIComponent(id)}`, {}),
|
|
@@ -36,11 +36,11 @@ export function FirstRunGuide() {
|
|
|
36
36
|
const readyProfile = compatProfiles.some((item) => item.chat_compatible || item.quality_status === "ok" || item.quality_status === "degraded");
|
|
37
37
|
|
|
38
38
|
const steps = [
|
|
39
|
-
{ label: "Make it yours", done: Boolean(profileData.email), icon: UserCircle, action: "account", detail: "
|
|
39
|
+
{ label: "Make it yours", done: Boolean(profileData.email), icon: UserCircle, action: "account", detail: "Choose the owner of this local AI Brain." },
|
|
40
40
|
{ label: "Choose a space", done: Boolean(registry.active_workspace || workspaceData.active_workspace), icon: Users, action: "workspace-admin", detail: "Decide where memories belong." },
|
|
41
|
-
{ label: "Meet your Mac", done: recs.isSuccess, icon: Cpu, action: "models", detail: "
|
|
42
|
-
{ label: "Pick a
|
|
43
|
-
{ label: "Install
|
|
41
|
+
{ label: "Meet your Mac", done: recs.isSuccess, icon: Cpu, action: "models", detail: "See what local Brain experience this computer can support." },
|
|
42
|
+
{ label: "Pick a voice", done: Boolean(topPick || currentModel), icon: Library, action: "models", detail: "Use the recommended model without rebuilding memory later." },
|
|
43
|
+
{ label: "Install with consent", done: Boolean(currentModel || loadedModels.length), icon: Download, action: "models", detail: "Download only after an explicit click." },
|
|
44
44
|
{ label: "Talk to Brain", done: Boolean(readyProfile || currentModel || loadedModels.length), icon: PlayCircle, action: "chat", detail: "Confirm the model can answer." },
|
|
45
45
|
{ label: "Set the pace", done: Boolean(mode), icon: SlidersHorizontal, action: "settings", detail: "Stay Calm or switch deeper." },
|
|
46
46
|
{ label: "Explore deeply", done: true, icon: Layers3, action: "knowledge-graph", detail: "Open advanced relationships." },
|
|
@@ -55,7 +55,7 @@ export function FirstRunGuide() {
|
|
|
55
55
|
<div className="page-kicker"><CheckCircle2 className="h-4 w-4" /> First 10 minutes</div>
|
|
56
56
|
<h2>Build your living Brain without guessing.</h2>
|
|
57
57
|
<p>
|
|
58
|
-
Start with a
|
|
58
|
+
Start with a local Brain, let Lattice recommend a model voice, then add the first pieces of durable knowledge.
|
|
59
59
|
Every step keeps the next action visible.
|
|
60
60
|
</p>
|
|
61
61
|
<div className="arrival-actions">
|
|
@@ -531,7 +531,7 @@ function buildDetectedFacts(analysis: FlowAnalysis | null) {
|
|
|
531
531
|
{
|
|
532
532
|
label: "Computer",
|
|
533
533
|
value: appleSilicon ? "Apple Silicon Mac" : friendlyOs(profile.os),
|
|
534
|
-
detail: "Ready for local
|
|
534
|
+
detail: "Ready for local Digital Brain use",
|
|
535
535
|
},
|
|
536
536
|
{
|
|
537
537
|
label: "Memory",
|
package/frontend/src/i18n.ts
CHANGED
|
@@ -13,11 +13,11 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
13
13
|
"language.ko": "한국어",
|
|
14
14
|
"language.en": "English",
|
|
15
15
|
"brain.level": "단계",
|
|
16
|
-
"brain.depth.1": "
|
|
17
|
-
"brain.depth.2": "
|
|
18
|
-
"brain.depth.3": "
|
|
19
|
-
"brain.depth.4": "
|
|
20
|
-
"brain.depth.5": "
|
|
16
|
+
"brain.depth.1": "지금 기억",
|
|
17
|
+
"brain.depth.2": "오래된 기억",
|
|
18
|
+
"brain.depth.3": "주제",
|
|
19
|
+
"brain.depth.4": "관계",
|
|
20
|
+
"brain.depth.5": "전체 지식 그래프",
|
|
21
21
|
"brain.view.memories": "기억 보기",
|
|
22
22
|
"brain.view.topics": "주제 보기",
|
|
23
23
|
"brain.view.relationships": "관계 보기",
|
|
@@ -29,11 +29,11 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
29
29
|
"brain.private": "개인 소유",
|
|
30
30
|
"brain.admin": "관리자 콘솔",
|
|
31
31
|
"brain.empty.kicker": "내 오래가는 기억",
|
|
32
|
-
"brain.empty.title": "잊으면 안 되는
|
|
33
|
-
"brain.empty.body": "문서, 대화, 프로젝트, 결정이 Brain에 쌓이고 나중에 주제와 관계로 다시 보입니다.",
|
|
34
|
-
"brain.prompt.remember": "이
|
|
35
|
-
"brain.prompt.know": "
|
|
36
|
-
"brain.prompt.plan": "
|
|
32
|
+
"brain.empty.title": "잊으면 안 되는 맥락부터 말해 주세요.",
|
|
33
|
+
"brain.empty.body": "문서, 대화, 프로젝트, 결정이 이 컴퓨터의 Brain에 쌓이고 나중에 주제와 관계로 다시 보입니다.",
|
|
34
|
+
"brain.prompt.remember": "이 프로젝트 목표를 기억해줘: ",
|
|
35
|
+
"brain.prompt.know": "이 문서를 내 Brain에 넣고 요약해줘: ",
|
|
36
|
+
"brain.prompt.plan": "지난 결정들을 나중에 찾을 수 있게 정리해줘: ",
|
|
37
37
|
"brain.placeholder": "Brain에게 말하기...",
|
|
38
38
|
"brain.image": "이미지",
|
|
39
39
|
"brain.unavailable": "지금은 답할 수 없음",
|
|
@@ -75,16 +75,16 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
75
75
|
"admin.kicker": "분리된 관리자 작업공간",
|
|
76
76
|
"admin.title": "Admin Console",
|
|
77
77
|
"admin.body": "사용자, 로그, 보안, Brain 상태는 일반 사용자 화면과 분리됩니다.",
|
|
78
|
-
"flow.shell": "내 로컬
|
|
79
|
-
"flow.login.title": "내
|
|
80
|
-
"flow.login.body": "
|
|
78
|
+
"flow.shell": "내 로컬 AI 브레인 만들기",
|
|
79
|
+
"flow.login.title": "내 AI 브레인의 주인을 정합니다.",
|
|
80
|
+
"flow.login.body": "Lattice AI는 모델이 바뀌어도 내 지식과 맥락을 보존하는 로컬 우선 AI 브레인입니다. 대화와 기억은 기본적으로 이 컴퓨터에 저장됩니다.",
|
|
81
81
|
"flow.name": "이름",
|
|
82
82
|
"flow.email": "이메일",
|
|
83
83
|
"flow.password": "비밀번호",
|
|
84
84
|
"flow.password.placeholder": "로컬 Brain 비밀번호",
|
|
85
85
|
"flow.login.busy": "Brain 여는 중...",
|
|
86
86
|
"flow.login.submit": "내 Brain 시작하기",
|
|
87
|
-
"flow.login.note": "기존 Brain과 다른 이메일이면 새로 만들지 않고 먼저 확인합니다.",
|
|
87
|
+
"flow.login.note": "이 프로필은 내 AI 브레인의 주인입니다. 기존 Brain과 다른 이메일이면 새로 만들지 않고 먼저 확인합니다.",
|
|
88
88
|
"flow.login.missing": "이름과 이메일, 비밀번호를 입력하면 기존 Brain을 안전하게 확인합니다.",
|
|
89
89
|
"flow.login.otherEmail": "이 컴퓨터의 기존 Brain과 다른 이메일입니다. 오타인지 확인해 주세요.",
|
|
90
90
|
"flow.login.wrongPassword": "기존 Brain 이메일은 맞지만 비밀번호가 다릅니다. 비밀번호를 다시 확인해 주세요.",
|
|
@@ -95,21 +95,21 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
95
95
|
"flow.promise.model.v": "모델은 목소리이고, 자산은 Brain입니다.",
|
|
96
96
|
"flow.promise.ownership.k": "사용자 소유",
|
|
97
97
|
"flow.promise.ownership.v": "백업, 복원, 이동이 가능합니다.",
|
|
98
|
-
"flow.analysis.title": "이
|
|
99
|
-
"flow.analysis.body": "
|
|
98
|
+
"flow.analysis.title": "이 컴퓨터에서 가능한 경험을 확인합니다.",
|
|
99
|
+
"flow.analysis.body": "스펙 점수가 아니라, 이 Mac에서 어떤 로컬 AI 브레인 경험이 편한지 알려드립니다. 클라우드 모델은 선택 사항입니다.",
|
|
100
100
|
"flow.analysis.finding": "가장 편한 설정을 찾는 중...",
|
|
101
101
|
"flow.analysis.ready": "추천 모델을 바로 시작할 수 있게 준비했습니다.",
|
|
102
102
|
"flow.analysis.wait": "잠시만 기다리면 자동으로 정리됩니다.",
|
|
103
103
|
"flow.analysis.error": "Lattice가 이 컴퓨터를 끝까지 확인하지 못했습니다. 그래도 안전한 기본값으로 계속할 수 있습니다.",
|
|
104
104
|
"flow.analysis.continue": "추천 모델 보기",
|
|
105
|
-
"flow.recommend.title": "
|
|
106
|
-
"flow.recommend.body": "모델은 Brain의 현재 목소리입니다.
|
|
105
|
+
"flow.recommend.title": "추천대로 시작하세요.",
|
|
106
|
+
"flow.recommend.body": "모델은 Brain의 현재 목소리입니다. 가장 안전한 추천, 더 빠른 모델, 더 강한 모델만 먼저 보여드립니다.",
|
|
107
107
|
"flow.recommend.primary": "추천대로 시작하기",
|
|
108
108
|
"flow.recommend.unsupported": "이 컴퓨터에서 추가 확인이 필요합니다",
|
|
109
109
|
"flow.recommend.back": "뒤로",
|
|
110
110
|
"flow.recommend.hint": "잘 모르겠다면 추천대로 시작하면 됩니다.",
|
|
111
111
|
"flow.install.title": "모델을 설치하고 시작합니다.",
|
|
112
|
-
"flow.install.body": "이 모델이 Brain의 로컬 목소리가 됩니다.
|
|
112
|
+
"flow.install.body": "이 모델이 Brain의 로컬 목소리가 됩니다. 다운로드할 때만 인터넷이 필요하고, 실행은 내 컴퓨터에서 진행됩니다.",
|
|
113
113
|
"flow.install.wait": "Brain이 사용할 모델을 기다리고 있습니다.",
|
|
114
114
|
"flow.install.prepare": "Brain 준비 중입니다.",
|
|
115
115
|
"flow.install.done": "Brain이 준비되었습니다.",
|
|
@@ -119,18 +119,18 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
119
119
|
"flow.install.start": "다운로드하고 시작하기",
|
|
120
120
|
"flow.install.busy": "모델 준비 중...",
|
|
121
121
|
"flow.install.enter": "Brain으로 들어가기",
|
|
122
|
-
"flow.install.local": "
|
|
122
|
+
"flow.install.local": "모델 다운로드와 외부 통신은 사용자가 시작할 때만 진행됩니다.",
|
|
123
123
|
},
|
|
124
124
|
en: {
|
|
125
125
|
"language.label": "Language",
|
|
126
126
|
"language.ko": "한국어",
|
|
127
127
|
"language.en": "English",
|
|
128
128
|
"brain.level": "Level",
|
|
129
|
-
"brain.depth.1": "
|
|
130
|
-
"brain.depth.2": "
|
|
131
|
-
"brain.depth.3": "
|
|
132
|
-
"brain.depth.4": "
|
|
133
|
-
"brain.depth.5": "
|
|
129
|
+
"brain.depth.1": "Now memory",
|
|
130
|
+
"brain.depth.2": "Older memory",
|
|
131
|
+
"brain.depth.3": "Topics",
|
|
132
|
+
"brain.depth.4": "Relationships",
|
|
133
|
+
"brain.depth.5": "Full knowledge graph",
|
|
134
134
|
"brain.view.memories": "Memories",
|
|
135
135
|
"brain.view.topics": "Topics",
|
|
136
136
|
"brain.view.relationships": "Relationships",
|
|
@@ -142,11 +142,11 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
142
142
|
"brain.private": "Private",
|
|
143
143
|
"brain.admin": "Admin Console",
|
|
144
144
|
"brain.empty.kicker": "Durable memory",
|
|
145
|
-
"brain.empty.title": "Start with
|
|
146
|
-
"brain.empty.body": "Documents, conversations, projects, and decisions accumulate in
|
|
147
|
-
"brain.prompt.remember": "Remember this
|
|
148
|
-
"brain.prompt.know": "
|
|
149
|
-
"brain.prompt.plan": "
|
|
145
|
+
"brain.empty.title": "Start with context that should not be forgotten.",
|
|
146
|
+
"brain.empty.body": "Documents, conversations, projects, and decisions accumulate in the Brain on this computer, then reappear as topics and relationships.",
|
|
147
|
+
"brain.prompt.remember": "Remember this project goal: ",
|
|
148
|
+
"brain.prompt.know": "Put this document into my Brain and summarize it: ",
|
|
149
|
+
"brain.prompt.plan": "Organize past decisions so I can find them later: ",
|
|
150
150
|
"brain.placeholder": "Talk to your Brain...",
|
|
151
151
|
"brain.image": "Image",
|
|
152
152
|
"brain.unavailable": "Unavailable",
|
|
@@ -188,16 +188,16 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
188
188
|
"admin.kicker": "Separate admin workspace",
|
|
189
189
|
"admin.title": "Admin Console",
|
|
190
190
|
"admin.body": "Users, logs, security, and Brain health stay out of the normal user experience.",
|
|
191
|
-
"flow.shell": "Create your local Brain",
|
|
192
|
-
"flow.login.title": "
|
|
193
|
-
"flow.login.body": "
|
|
191
|
+
"flow.shell": "Create your local AI Brain",
|
|
192
|
+
"flow.login.title": "Choose the owner of your AI Brain.",
|
|
193
|
+
"flow.login.body": "Lattice AI is a local-first Digital Brain that keeps your knowledge durable across any AI model. Conversations and memories are stored on this computer by default.",
|
|
194
194
|
"flow.name": "Name",
|
|
195
195
|
"flow.email": "Email",
|
|
196
196
|
"flow.password": "Password",
|
|
197
197
|
"flow.password.placeholder": "Local Brain password",
|
|
198
198
|
"flow.login.busy": "Opening Brain...",
|
|
199
199
|
"flow.login.submit": "Start my Brain",
|
|
200
|
-
"flow.login.note": "If the email differs from this computer's Brain, Lattice checks before creating anything new.",
|
|
200
|
+
"flow.login.note": "This profile owns your AI Brain. If the email differs from this computer's Brain, Lattice checks before creating anything new.",
|
|
201
201
|
"flow.login.missing": "Enter your name, email, and password so Lattice can safely check your Brain.",
|
|
202
202
|
"flow.login.otherEmail": "This email differs from the Brain on this computer. Please check for a typo.",
|
|
203
203
|
"flow.login.wrongPassword": "The email matches this Brain, but the password is different. Please check it again.",
|
|
@@ -208,21 +208,21 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
208
208
|
"flow.promise.model.v": "The model is the voice; the Brain is the asset.",
|
|
209
209
|
"flow.promise.ownership.k": "User owned",
|
|
210
210
|
"flow.promise.ownership.v": "Back up, restore, and move it.",
|
|
211
|
-
"flow.analysis.title": "Checking this computer.",
|
|
212
|
-
"flow.analysis.body": "
|
|
211
|
+
"flow.analysis.title": "Checking what this computer can do.",
|
|
212
|
+
"flow.analysis.body": "This is not a spec test. Lattice explains what local AI Brain experience this Mac can support. Cloud models remain optional.",
|
|
213
213
|
"flow.analysis.finding": "Finding the easiest setup...",
|
|
214
214
|
"flow.analysis.ready": "Your recommended model is ready to start.",
|
|
215
215
|
"flow.analysis.wait": "This will be summarized automatically in a moment.",
|
|
216
216
|
"flow.analysis.error": "Lattice could not finish checking this computer. You can still continue with a safe default.",
|
|
217
217
|
"flow.analysis.continue": "View recommended models",
|
|
218
|
-
"flow.recommend.title": "Start with the
|
|
219
|
-
"flow.recommend.body": "The model is your Brain's current voice. You
|
|
218
|
+
"flow.recommend.title": "Start with the recommendation.",
|
|
219
|
+
"flow.recommend.body": "The model is your Brain's current voice. You see the safest recommendation, a faster model, and a stronger model first.",
|
|
220
220
|
"flow.recommend.primary": "Start with recommendation",
|
|
221
221
|
"flow.recommend.unsupported": "This computer needs one more check",
|
|
222
222
|
"flow.recommend.back": "Back",
|
|
223
223
|
"flow.recommend.hint": "If unsure, start with the recommendation.",
|
|
224
224
|
"flow.install.title": "Install the model and start.",
|
|
225
|
-
"flow.install.body": "This model becomes your Brain's local voice.
|
|
225
|
+
"flow.install.body": "This model becomes your Brain's local voice. Internet is needed only for download; execution happens on this computer.",
|
|
226
226
|
"flow.install.wait": "Waiting for the model your Brain will use.",
|
|
227
227
|
"flow.install.prepare": "Preparing your Brain.",
|
|
228
228
|
"flow.install.done": "Your Brain is ready.",
|
|
@@ -232,7 +232,7 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
232
232
|
"flow.install.start": "Download and start",
|
|
233
233
|
"flow.install.busy": "Preparing model...",
|
|
234
234
|
"flow.install.enter": "Enter Brain",
|
|
235
|
-
"flow.install.local": "
|
|
235
|
+
"flow.install.local": "Downloads and external communication start only when you choose them.",
|
|
236
236
|
},
|
|
237
237
|
};
|
|
238
238
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
3
|
import ReactFlow, { Background, Controls, Edge, Node } from "reactflow";
|
|
4
|
-
import { Bot, GitBranch, PauseCircle, Play, Workflow } from "lucide-react";
|
|
4
|
+
import { Bot, CalendarClock, GitBranch, PauseCircle, Play, ShieldCheck, Workflow } from "lucide-react";
|
|
5
5
|
import { latticeApi } from "@/api/client";
|
|
6
6
|
import { ActionButton, DataPanel, EntityList, KeyValueList, ModeGate, OperationResult, StructuredView, Tabs } from "@/components/primitives";
|
|
7
7
|
import { Badge } from "@/components/ui/badge";
|
|
@@ -199,6 +199,7 @@ function WorkflowsPanel() {
|
|
|
199
199
|
const qc = useQueryClient();
|
|
200
200
|
const defs = useQuery({ queryKey: ["workflowDefinitions"], queryFn: latticeApi.workflowDefinitions });
|
|
201
201
|
const triggers = useQuery({ queryKey: ["workflowTriggers"], queryFn: latticeApi.workflowTriggers });
|
|
202
|
+
const recipes = useQuery({ queryKey: ["automationRecipes"], queryFn: latticeApi.automationRecipes });
|
|
202
203
|
const [name, setName] = React.useState("Manual workflow");
|
|
203
204
|
const [importText, setImportText] = React.useState("");
|
|
204
205
|
const create = useMutation({
|
|
@@ -216,7 +217,19 @@ function WorkflowsPanel() {
|
|
|
216
217
|
qc.invalidateQueries({ queryKey: ["workflowDefinitions"] });
|
|
217
218
|
},
|
|
218
219
|
});
|
|
220
|
+
const installRecipe = useMutation({
|
|
221
|
+
mutationFn: (recipeId: string) => latticeApi.installAutomationRecipe(recipeId, false),
|
|
222
|
+
onSuccess: () => {
|
|
223
|
+
qc.invalidateQueries({ queryKey: ["workflowDefinitions"] });
|
|
224
|
+
qc.invalidateQueries({ queryKey: ["workflowTriggers"] });
|
|
225
|
+
},
|
|
226
|
+
});
|
|
219
227
|
const workflows = asArray<Record<string, unknown>>((defs.data?.data as Record<string, unknown>)?.workflows);
|
|
228
|
+
const installedRecipeIds = new Set(
|
|
229
|
+
workflows
|
|
230
|
+
.filter((w: any) => w && w.metadata && w.metadata.created_from === "brain_automation_recipe" && w.metadata.recipe_id)
|
|
231
|
+
.map((w: any) => String(w.metadata.recipe_id))
|
|
232
|
+
);
|
|
220
233
|
const nodes: Node[] = workflows.slice(0, 12).map((workflow, index) => ({
|
|
221
234
|
id: String(workflow.id || workflow.workflow_id || index),
|
|
222
235
|
position: { x: (index % 4) * 190, y: Math.floor(index / 4) * 120 },
|
|
@@ -225,6 +238,74 @@ function WorkflowsPanel() {
|
|
|
225
238
|
const edges: Edge[] = nodes.slice(1).map((node, index) => ({ id: `e-${index}`, source: nodes[index].id, target: node.id }));
|
|
226
239
|
return (
|
|
227
240
|
<div className="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
|
|
241
|
+
<DataPanel title="Brain automations" result={recipes.data} className="xl:col-span-2">
|
|
242
|
+
{(data) => {
|
|
243
|
+
const items = asArray<Record<string, unknown>>((data as Record<string, unknown>).recipes);
|
|
244
|
+
return (
|
|
245
|
+
<div className="grid gap-3 lg:grid-cols-3">
|
|
246
|
+
{items.map((recipe) => {
|
|
247
|
+
const id = String(recipe.id || "");
|
|
248
|
+
const consent = (recipe.consent || {}) as Record<string, unknown>;
|
|
249
|
+
const creates = asArray<string>(recipe.creates);
|
|
250
|
+
return (
|
|
251
|
+
<div key={id} className="rounded-lg border border-border bg-background/70 p-4">
|
|
252
|
+
<div className="flex items-start justify-between gap-2">
|
|
253
|
+
<div>
|
|
254
|
+
<div className="flex items-center gap-2 font-medium">
|
|
255
|
+
<CalendarClock className="h-4 w-4" /> {String(recipe.name || id)}
|
|
256
|
+
</div>
|
|
257
|
+
<p className="mt-2 text-sm text-muted-foreground">{String(recipe.summary || "")}</p>
|
|
258
|
+
</div>
|
|
259
|
+
<Badge variant="muted">{String(recipe.cadence || "draft")}</Badge>
|
|
260
|
+
</div>
|
|
261
|
+
<p className="mt-3 text-sm">{String(recipe.user_value || "")}</p>
|
|
262
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
263
|
+
<Badge variant="success"><ShieldCheck className="h-3 w-3" /> local only</Badge>
|
|
264
|
+
{consent.requires_user_enable ? <Badge variant="warning">draft first</Badge> : null}
|
|
265
|
+
{creates.slice(0, 2).map((item) => <Badge key={item} variant="muted">{item}</Badge>)}
|
|
266
|
+
</div>
|
|
267
|
+
{(() => {
|
|
268
|
+
const isInstalling = installRecipe.isPending && installRecipe.variables === id;
|
|
269
|
+
const last = installRecipe.data as any;
|
|
270
|
+
const lastRid = last && last.recipe && last.recipe.recipe_id ? String(last.recipe.recipe_id) : "";
|
|
271
|
+
const justSucceeded = !installRecipe.isPending && lastRid === id;
|
|
272
|
+
const installed = installedRecipeIds.has(id);
|
|
273
|
+
const btnLabel = isInstalling
|
|
274
|
+
? "Creating..."
|
|
275
|
+
: justSucceeded
|
|
276
|
+
? "✓ Draft created"
|
|
277
|
+
: installed
|
|
278
|
+
? "Draft already created"
|
|
279
|
+
: "Create reviewable draft";
|
|
280
|
+
return (
|
|
281
|
+
<>
|
|
282
|
+
<Button
|
|
283
|
+
className="mt-4 w-full"
|
|
284
|
+
variant={installed || justSucceeded ? "secondary" : "outline"}
|
|
285
|
+
disabled={!id || isInstalling || installed}
|
|
286
|
+
onClick={() => {
|
|
287
|
+
if (installed || isInstalling) return;
|
|
288
|
+
installRecipe.mutate(id);
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
{btnLabel}
|
|
292
|
+
</Button>
|
|
293
|
+
{justSucceeded ? (
|
|
294
|
+
<p className="mt-1 text-[10px] text-green-600">Reviewable draft ready. Check Definitions below.</p>
|
|
295
|
+
) : null}
|
|
296
|
+
{installed && !justSucceeded ? (
|
|
297
|
+
<p className="mt-1 text-[10px] text-muted-foreground">Reviewable draft exists — see Definitions.</p>
|
|
298
|
+
) : null}
|
|
299
|
+
</>
|
|
300
|
+
);
|
|
301
|
+
})()}
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
})}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}}
|
|
308
|
+
</DataPanel>
|
|
228
309
|
<Card>
|
|
229
310
|
<CardHeader>
|
|
230
311
|
<CardTitle className="flex items-center gap-2"><GitBranch className="h-4 w-4" /> Workflow graph</CardTitle>
|
|
@@ -30,8 +30,8 @@ export function LibraryPage({ initialTab }: { initialTab?: string }) {
|
|
|
30
30
|
<div className="space-y-5">
|
|
31
31
|
<header className="page-hero">
|
|
32
32
|
<div className="page-kicker"><Boxes className="h-4 w-4" /> Library</div>
|
|
33
|
-
<h1 className="page-title">Choose
|
|
34
|
-
<p className="page-copy">
|
|
33
|
+
<h1 className="page-title">Choose your Brain's voice.</h1>
|
|
34
|
+
<p className="page-copy">Start with a short local model recommendation. Your Brain and memories stay durable when you switch later.</p>
|
|
35
35
|
</header>
|
|
36
36
|
<Tabs tabs={visibleTabs} value={tab} onChange={(id) => setTab(id as LibraryTab)} />
|
|
37
37
|
{tab === "models" ? <ModelsPanel /> : null}
|
|
@@ -103,7 +103,7 @@ function ModelsPanel() {
|
|
|
103
103
|
return (
|
|
104
104
|
<div className="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
|
|
105
105
|
<div className="space-y-4">
|
|
106
|
-
<DataPanel title="Guided model setup" description="
|
|
106
|
+
<DataPanel title="Guided model setup" description="Lattice recommends a small set first, explains internet/download needs, and keeps every model download behind consent." result={recs.data}>
|
|
107
107
|
{(data) => {
|
|
108
108
|
const recommendation = (data as Record<string, unknown>).recommendations as Record<string, unknown> | undefined;
|
|
109
109
|
const profile = (data as Record<string, unknown>).profile as Record<string, unknown> | undefined;
|
|
@@ -134,7 +134,7 @@ function ModelsPanel() {
|
|
|
134
134
|
<label className="flex items-start gap-2 rounded-lg border border-border bg-background/55 p-3 text-sm leading-6">
|
|
135
135
|
<input className="mt-1" type="checkbox" checked={consent} onChange={(event) => setConsent(event.target.checked)} />
|
|
136
136
|
<span>
|
|
137
|
-
Allow Lattice to install a missing local model component or download model files for this action.
|
|
137
|
+
Allow Lattice to install a missing local model component or download model files for this action. Download uses the internet; running the model stays local.
|
|
138
138
|
</span>
|
|
139
139
|
</label>
|
|
140
140
|
{latestProgress ? (
|
|
@@ -151,10 +151,10 @@ function ModelsPanel() {
|
|
|
151
151
|
);
|
|
152
152
|
}}
|
|
153
153
|
</DataPanel>
|
|
154
|
-
<DataPanel title="Recommended models" result={models.data}>
|
|
154
|
+
<DataPanel title={mode === "basic" ? "Recommended models" : "Recommended and advanced models"} result={models.data}>
|
|
155
155
|
{(data) => (
|
|
156
156
|
<div className="grid gap-2">
|
|
157
|
-
{(catalog.length ? catalog : asArray<Record<string, unknown>>((data as Record<string, unknown>).loaded)).slice(0, 14).map((model, index) => {
|
|
157
|
+
{(catalog.length ? catalog : asArray<Record<string, unknown>>((data as Record<string, unknown>).loaded)).slice(0, mode === "basic" ? 3 : 14).map((model, index) => {
|
|
158
158
|
const id = String(model.id || model.model_id || model.name || index);
|
|
159
159
|
const loaded = asArray<string>((data as Record<string, unknown>).loaded).includes(id) || (data as Record<string, unknown>).current === id || model.state === "loaded";
|
|
160
160
|
const loadId = String(model.recommended_load_id || id);
|
|
@@ -178,6 +178,8 @@ function ModelsPanel() {
|
|
|
178
178
|
const actionLabel = String(compatibility.action || loadStatus.replace(/_/g, " "));
|
|
179
179
|
const badgeLabel = unsupported && mode === "basic" ? "needs attention" : unsupported ? actionLabel : loadStatus;
|
|
180
180
|
const canPrepare = loadAvailable || downloadRequired;
|
|
181
|
+
const downloadSize = model.download_size || model.estimated_size || recommendation.download_size || recommendation.estimated_size || model.size || recommendation.size;
|
|
182
|
+
const maker = model.provider || recommendation.provider || model.organization || recommendation.organization;
|
|
181
183
|
return (
|
|
182
184
|
<div key={id} className="grid gap-3 rounded-lg border border-border bg-background/55 p-4 md:grid-cols-[1fr_auto]">
|
|
183
185
|
<div className="min-w-0">
|
|
@@ -200,6 +202,11 @@ function ModelsPanel() {
|
|
|
200
202
|
{String(hardwareNote)}
|
|
201
203
|
</div>
|
|
202
204
|
) : null}
|
|
205
|
+
<div className="mt-2 flex flex-wrap gap-1 text-[11px] text-muted-foreground">
|
|
206
|
+
<Badge variant="muted">{downloadRequired ? `Download: ${String(downloadSize || "required")}` : "No download needed now"}</Badge>
|
|
207
|
+
<Badge variant="muted">{downloadRequired ? "Internet only during download" : "Runs locally when loaded"}</Badge>
|
|
208
|
+
{maker ? <Badge variant="muted">{String(maker)}</Badge> : null}
|
|
209
|
+
</div>
|
|
203
210
|
{unsupported ? (
|
|
204
211
|
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm">
|
|
205
212
|
<div className="font-medium">{mode === "basic" ? "Needs attention before loading" : actionLabel}</div>
|
|
@@ -242,6 +249,11 @@ function ModelsPanel() {
|
|
|
242
249
|
</div>
|
|
243
250
|
);
|
|
244
251
|
})}
|
|
252
|
+
{mode === "basic" && catalog.length > 3 ? (
|
|
253
|
+
<div className="rounded-lg border border-border bg-background/55 p-3 text-sm text-muted-foreground">
|
|
254
|
+
Showing the safest short list. Switch to Advanced for the full registry, runtime details, licenses, and safety notes.
|
|
255
|
+
</div>
|
|
256
|
+
) : null}
|
|
245
257
|
</div>
|
|
246
258
|
)}
|
|
247
259
|
</DataPanel>
|
package/lattice_brain/archive.py
CHANGED
|
@@ -14,6 +14,7 @@ import io
|
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
16
|
import shutil
|
|
17
|
+
import sqlite3
|
|
17
18
|
import tempfile
|
|
18
19
|
import zipfile
|
|
19
20
|
from dataclasses import dataclass
|
|
@@ -111,6 +112,16 @@ def _sqlite_siblings(db_path: Path) -> tuple[Path, Path, Path]:
|
|
|
111
112
|
return (db_path, Path(str(db_path) + "-wal"), Path(str(db_path) + "-shm"))
|
|
112
113
|
|
|
113
114
|
|
|
115
|
+
def _checkpoint_sqlite(db_path: Path) -> None:
|
|
116
|
+
if not db_path.exists():
|
|
117
|
+
return
|
|
118
|
+
try:
|
|
119
|
+
with sqlite3.connect(str(db_path)) as conn:
|
|
120
|
+
conn.execute("PRAGMA wal_checkpoint(FULL)")
|
|
121
|
+
except sqlite3.Error:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
|
|
114
125
|
def _restore_sibling(path: Path, backup: Path) -> None:
|
|
115
126
|
if backup.exists():
|
|
116
127
|
shutil.copy2(backup, path)
|
|
@@ -124,6 +135,7 @@ def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
|
|
|
124
135
|
shutil.copyfile(src, tmp)
|
|
125
136
|
backups: dict[Path, Path] = {}
|
|
126
137
|
try:
|
|
138
|
+
_checkpoint_sqlite(dest)
|
|
127
139
|
for sibling in _sqlite_siblings(dest):
|
|
128
140
|
if sibling.exists():
|
|
129
141
|
backup = backup_dir / sibling.name
|
|
@@ -17,6 +17,7 @@ import hashlib
|
|
|
17
17
|
import json
|
|
18
18
|
import os
|
|
19
19
|
import shutil
|
|
20
|
+
import sqlite3
|
|
20
21
|
import tempfile
|
|
21
22
|
import zipfile
|
|
22
23
|
from datetime import datetime, timezone
|
|
@@ -72,6 +73,18 @@ def _sqlite_siblings(db_path: Path) -> tuple[Path, Path, Path]:
|
|
|
72
73
|
return (db_path, Path(str(db_path) + "-wal"), Path(str(db_path) + "-shm"))
|
|
73
74
|
|
|
74
75
|
|
|
76
|
+
def _checkpoint_sqlite(db_path: Path) -> None:
|
|
77
|
+
if not db_path.exists():
|
|
78
|
+
return
|
|
79
|
+
try:
|
|
80
|
+
with sqlite3.connect(str(db_path)) as conn:
|
|
81
|
+
conn.execute("PRAGMA wal_checkpoint(FULL)")
|
|
82
|
+
except sqlite3.Error:
|
|
83
|
+
# Best-effort only. Existing sibling backup/restore still preserves
|
|
84
|
+
# the WAL files if a live connection prevents a checkpoint.
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
|
|
75
88
|
def _restore_sibling(path: Path, backup: Path) -> None:
|
|
76
89
|
if backup.exists():
|
|
77
90
|
shutil.copy2(backup, path)
|
|
@@ -85,6 +98,7 @@ def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
|
|
|
85
98
|
shutil.copyfile(src, tmp)
|
|
86
99
|
backups: dict[Path, Path] = {}
|
|
87
100
|
try:
|
|
101
|
+
_checkpoint_sqlite(dest)
|
|
88
102
|
# -wal/-shm are transient: another live connection can checkpoint and
|
|
89
103
|
# remove them between exists() and the copy/unlink. Treat a vanished
|
|
90
104
|
# sibling as "nothing to preserve" instead of crashing the restore.
|
|
@@ -3,6 +3,59 @@
|
|
|
3
3
|
Physically hosts the hooks registry/dispatch lifecycle, the multi-agent
|
|
4
4
|
orchestrator, and the agent runtime service. Lazy-loaded so importing
|
|
5
5
|
``lattice_brain.runtime`` stays cheap.
|
|
6
|
+
|
|
7
|
+
=== lattice_brain/runtime 책임 + 의존성 그래프 (A방향 + Act/automation 기준) ===
|
|
8
|
+
- multi_agent.py (핵심 실행체)
|
|
9
|
+
책임: OrchestrationContext, AgentContextPacket, AgentHandoff, AgentRunResult,
|
|
10
|
+
MultiAgentOrchestrator, default_role_runner, llm_role_runner,
|
|
11
|
+
CORE_PIPELINE/AGENT_ROLES, handoff/review/retry 타임라인 생성.
|
|
12
|
+
의존: 없음 (순수, deterministic/LLM runner 주입).
|
|
13
|
+
진입: orchestrator_factory 로 AgentRuntime에 주입됨.
|
|
14
|
+
|
|
15
|
+
- hooks.py (라이프사이클 확장)
|
|
16
|
+
책임: HookContext/HookResult, HooksRegistry (builtin+user, order/enabled persist),
|
|
17
|
+
dispatch_tool (pre_tool/post_tool 통합), fire_hook/run_hooks,
|
|
18
|
+
BUILTIN_HOOKS (redact, memory-snapshot, tool-permission-gate 등).
|
|
19
|
+
의존: subprocess (user command hooks).
|
|
20
|
+
진입: AgentRuntime, tool_dispatch, api/tools, core/agent 등에 주입/등록.
|
|
21
|
+
|
|
22
|
+
- agent_runtime.py (공개 퍼사드 / 바운더리)
|
|
23
|
+
책임: AgentRuntime (store+orchestrator_factory+workspace_graph+audit+hooks 주입),
|
|
24
|
+
start/reserve_run/complete_reserved_run, stop, status/health/config,
|
|
25
|
+
list_runs/get_run/events/replay, _fire_pre_run / _post_run_hooks.
|
|
26
|
+
의존: .multi_agent, .hooks (간접), store (WORKSPACE_OS).
|
|
27
|
+
진입점: **여기가 제품 경계**. app_factory에서 AGENT_RUNTIME으로 생성,
|
|
28
|
+
api/agents.py (런타임 라우터), RunExecutor (async agent/workflow),
|
|
29
|
+
workflow_designer에 주입. frontend BrainAutomationPanel / Act 가
|
|
30
|
+
/agents/* 를 통해 이 바운더리만 의존.
|
|
31
|
+
|
|
32
|
+
- __init__.py : lazy re-export (import 비용 최소화).
|
|
33
|
+
|
|
34
|
+
실제 진입점 매핑 (app_factory + 호출 스택):
|
|
35
|
+
app_factory.py:1508
|
|
36
|
+
AGENT_RUNTIME = AgentRuntime(store=WORKSPACE_OS, orchestrator_factory=PLATFORM.build_orchestrator, hooks=HOOKS_REGISTRY, ...)
|
|
37
|
+
RUN_EXECUTOR = RunExecutor(..., agent_runtime=AGENT_RUNTIME)
|
|
38
|
+
AGENT_RUNTIME.attach_executor(RUN_EXECUTOR)
|
|
39
|
+
create_agents_router(..., agent_runtime=AGENT_RUNTIME)
|
|
40
|
+
create_workflow_designer_router(..., run_executor=RUN_EXECUTOR, trigger_service=TRIGGER_SERVICE)
|
|
41
|
+
api/agents.py:49
|
|
42
|
+
from lattice_brain.runtime.agent_runtime import AgentRuntime
|
|
43
|
+
runtime = agent_runtime or AgentRuntime(...) # fallback
|
|
44
|
+
/agents/api/runtime/* , POST /agents (start via runtime.start or executor)
|
|
45
|
+
api/chat.py
|
|
46
|
+
from latticeai.services.tool_dispatch import build_agent_runtime
|
|
47
|
+
-> latticeai/core/agent.py:AgentRuntime (별도 state/plan/transcript 머신, single-agent /agent 경로. dispatch_tool 만 공유)
|
|
48
|
+
latticeai/services/tool_dispatch.py:14
|
|
49
|
+
from latticeai.core.agent import AgentRuntime as CoreAgentRuntime
|
|
50
|
+
(tool governance + core single-agent용)
|
|
51
|
+
latticeai/services/platform_runtime.py
|
|
52
|
+
from lattice_brain.runtime.{hooks, multi_agent}
|
|
53
|
+
tests: test_hooks_dispatch.py, test_t7_triggers.py 등에서 직접 import lattice_brain.runtime.*
|
|
54
|
+
|
|
55
|
+
core/tool_registry.py (신규) + services/tool_dispatch.py 가 tool build 주도.
|
|
56
|
+
lattice_brain/runtime 는 multi-agent + hooks + facade 에 집중. core/agent 는 chat/agent 단일 루프.
|
|
57
|
+
|
|
58
|
+
이 매핑으로 중복 제거 및 wiring 명확화 완료 (feat(Act, automation) 방향).
|
|
6
59
|
"""
|
|
7
60
|
|
|
8
61
|
from __future__ import annotations
|