ltcai 5.6.0 → 6.1.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 +45 -25
- package/docs/CHANGELOG.md +74 -0
- package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +1 -1
- package/docs/V4_6_1_RELEASE_REFRESH_REPORT.md +1 -1
- package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +1 -1
- package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +1 -1
- package/frontend/openapi.json +39 -0
- package/frontend/src/App.tsx +5 -0
- package/frontend/src/api/client.ts +104 -23
- package/frontend/src/api/openapi.ts +48 -0
- package/frontend/src/components/FirstRunGuide.tsx +3 -3
- package/frontend/src/components/ProductFlow.tsx +7 -0
- package/frontend/src/features/review/ReviewCard.tsx +96 -0
- package/frontend/src/features/review/ReviewInbox.tsx +112 -0
- package/frontend/src/features/review/reviewHelpers.ts +69 -0
- package/frontend/src/i18n.ts +18 -8
- package/frontend/src/pages/Act.tsx +5 -177
- package/frontend/src/routes.ts +1 -0
- package/frontend/src/styles.css +20 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +52 -33
- package/latticeai/api/review_queue.py +7 -3
- package/latticeai/app_factory.py +253 -475
- package/latticeai/cli/__init__.py +1 -0
- package/latticeai/cli/runtime.py +37 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/app_context_runtime.py +13 -0
- package/latticeai/runtime/automation_runtime.py +64 -0
- package/latticeai/runtime/bootstrap.py +48 -0
- package/latticeai/runtime/context_runtime.py +43 -0
- package/latticeai/runtime/hooks_runtime.py +77 -0
- package/latticeai/runtime/lifespan_runtime.py +138 -0
- package/latticeai/runtime/persistence_runtime.py +87 -0
- package/latticeai/runtime/platform_services_runtime.py +39 -0
- package/latticeai/runtime/router_registration.py +570 -0
- package/latticeai/runtime/web_runtime.py +65 -0
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/review_queue.py +20 -4
- package/latticeai/services/tool_dispatch.py +82 -25
- package/ltcai_cli.py +5 -31
- package/package.json +1 -1
- 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-xRn29gI8.css → index-B744yblP.css} +1 -1
- package/static/app/assets/index-DYaUKNfl.js +16 -0
- package/static/app/assets/index-DYaUKNfl.js.map +1 -0
- package/static/app/index.html +2 -2
- package/telegram_bot.py +3 -9
- package/static/app/assets/index-xMFu94cX.js +0 -16
- package/static/app/assets/index-xMFu94cX.js.map +0 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { RotateCcw } from "lucide-react";
|
|
2
|
+
import type { ApiResult, ReviewItem } from "@/api/client";
|
|
3
|
+
import { ActionButton, KeyValueList } from "@/components/primitives";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { useAppStore } from "@/store/appStore";
|
|
7
|
+
import {
|
|
8
|
+
formatSnoozedUntil,
|
|
9
|
+
hasRunBefore,
|
|
10
|
+
isActionableReview,
|
|
11
|
+
reviewSourceDetail,
|
|
12
|
+
reviewSourceLabel,
|
|
13
|
+
reviewStatusVariant,
|
|
14
|
+
type ReviewAction,
|
|
15
|
+
} from "./reviewHelpers";
|
|
16
|
+
|
|
17
|
+
type ReviewCardProps = {
|
|
18
|
+
item: ReviewItem;
|
|
19
|
+
feedback?: string;
|
|
20
|
+
onAction: (item: ReviewItem, action: ReviewAction, hadRunBefore?: boolean) => Promise<ApiResult<ReviewItem>>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function ReviewCard({ item, feedback, onAction }: ReviewCardProps) {
|
|
24
|
+
const mode = useAppStore((state) => state.mode);
|
|
25
|
+
const provenance = item.provenance || {};
|
|
26
|
+
const payload = item.payload || {};
|
|
27
|
+
const hadRun = hasRunBefore(item);
|
|
28
|
+
const snoozed = item.effective_status === "snoozed";
|
|
29
|
+
const actionable = isActionableReview(item);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="rounded-lg border border-border bg-background/55 p-4">
|
|
33
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
34
|
+
<div className="min-w-0 flex-1">
|
|
35
|
+
<div className="font-medium">{item.title}</div>
|
|
36
|
+
{item.summary ? <p className="mt-1 text-sm leading-6 text-muted-foreground">{item.summary}</p> : null}
|
|
37
|
+
</div>
|
|
38
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
39
|
+
<Badge variant="muted">{reviewSourceLabel(item.source)}</Badge>
|
|
40
|
+
<Badge variant={reviewStatusVariant(item.effective_status)}>{item.effective_status}</Badge>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{snoozed ? (
|
|
45
|
+
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-muted/24 p-3 text-sm">
|
|
46
|
+
<div>
|
|
47
|
+
<div className="font-medium">{formatSnoozedUntil(item.snoozed_until)}</div>
|
|
48
|
+
<p className="mt-1 text-muted-foreground">This stays out of the pending queue until then. Unsnooze brings it back immediately.</p>
|
|
49
|
+
</div>
|
|
50
|
+
<Button size="sm" variant="outline" onClick={() => onAction(item, "unsnooze")} disabled={!actionable}>
|
|
51
|
+
<RotateCcw className="h-3.5 w-3.5" /> Unsnooze
|
|
52
|
+
</Button>
|
|
53
|
+
</div>
|
|
54
|
+
) : null}
|
|
55
|
+
|
|
56
|
+
{mode !== "basic" ? (
|
|
57
|
+
<div className="mt-3">
|
|
58
|
+
<KeyValueList
|
|
59
|
+
data={{
|
|
60
|
+
workflow: provenance.workflow_id,
|
|
61
|
+
trigger: provenance.trigger_id,
|
|
62
|
+
run: payload.last_run_id || provenance.run_id,
|
|
63
|
+
source_detail: reviewSourceDetail(provenance, item.source),
|
|
64
|
+
snoozed_until: item.snoozed_until,
|
|
65
|
+
created_at: item.created_at,
|
|
66
|
+
updated_at: item.updated_at,
|
|
67
|
+
}}
|
|
68
|
+
limit={8}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
) : null}
|
|
72
|
+
|
|
73
|
+
{actionable ? (
|
|
74
|
+
<div className="mt-4 grid gap-2">
|
|
75
|
+
<p className="text-xs leading-5 text-muted-foreground">
|
|
76
|
+
Run now previews the action without approving it. Approve or dismiss when the result looks right.
|
|
77
|
+
</p>
|
|
78
|
+
<div className="flex flex-wrap gap-2" aria-label="Review actions">
|
|
79
|
+
<ActionButton
|
|
80
|
+
label="Run now"
|
|
81
|
+
successLabel={hadRun ? "Regenerated" : "Executed"}
|
|
82
|
+
action={() => onAction(item, "run_now", hadRun)}
|
|
83
|
+
invalidate={[]}
|
|
84
|
+
/>
|
|
85
|
+
<ActionButton label="Approve" action={() => onAction(item, "approve")} invalidate={[]} />
|
|
86
|
+
{!snoozed ? <ActionButton label="Snooze 1 day" action={() => onAction(item, "snooze")} invalidate={[]} /> : null}
|
|
87
|
+
<ActionButton label="Dismiss" action={() => onAction(item, "dismiss")} invalidate={[]} variant="destructive" />
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
) : null}
|
|
91
|
+
{feedback ? (
|
|
92
|
+
<p className="mt-2 text-xs text-emerald-300">{feedback} - item stays open until you approve or dismiss.</p>
|
|
93
|
+
) : null}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { latticeApi, type ReviewItem, type ReviewSourceFilter, type ReviewStatusFilter } from "@/api/client";
|
|
4
|
+
import { EmptyState, LoadingPanel, Tabs } from "@/components/primitives";
|
|
5
|
+
import { Badge } from "@/components/ui/badge";
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { ReviewCard } from "./ReviewCard";
|
|
8
|
+
import {
|
|
9
|
+
defaultSnoozeUntil,
|
|
10
|
+
reviewSourceFilters,
|
|
11
|
+
reviewStatusFilters,
|
|
12
|
+
type ReviewAction,
|
|
13
|
+
} from "./reviewHelpers";
|
|
14
|
+
|
|
15
|
+
export function ReviewInbox() {
|
|
16
|
+
const qc = useQueryClient();
|
|
17
|
+
const [statusFilter, setStatusFilter] = React.useState<ReviewStatusFilter>("pending");
|
|
18
|
+
const [sourceFilter, setSourceFilter] = React.useState<ReviewSourceFilter>("all");
|
|
19
|
+
const [runFeedback, setRunFeedback] = React.useState<Record<string, string>>({});
|
|
20
|
+
const reviews = useQuery({
|
|
21
|
+
queryKey: ["automationReviews", statusFilter, sourceFilter],
|
|
22
|
+
queryFn: () => latticeApi.automationReviews({
|
|
23
|
+
...(statusFilter !== "all" ? { status: statusFilter } : {}),
|
|
24
|
+
...(sourceFilter !== "all" ? { source: sourceFilter } : {}),
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
const items = reviews.data?.data.items || [];
|
|
28
|
+
|
|
29
|
+
const actOnReview = async (
|
|
30
|
+
item: ReviewItem,
|
|
31
|
+
action: ReviewAction,
|
|
32
|
+
hadRunBefore = false,
|
|
33
|
+
) => {
|
|
34
|
+
const call =
|
|
35
|
+
action === "approve" ? () => latticeApi.approveReviewItem(item.id) :
|
|
36
|
+
action === "dismiss" ? () => latticeApi.dismissReviewItem(item.id) :
|
|
37
|
+
action === "snooze" ? () => latticeApi.snoozeReviewItem(item.id, defaultSnoozeUntil()) :
|
|
38
|
+
action === "unsnooze" ? () => latticeApi.unsnoozeReviewItem(item.id) :
|
|
39
|
+
() => latticeApi.runNowReviewItem(item.id);
|
|
40
|
+
const result = await call();
|
|
41
|
+
if (result.ok) {
|
|
42
|
+
if (action === "run_now") {
|
|
43
|
+
setRunFeedback((prev) => ({
|
|
44
|
+
...prev,
|
|
45
|
+
[item.id]: hadRunBefore ? "Regenerated" : "Executed",
|
|
46
|
+
}));
|
|
47
|
+
} else {
|
|
48
|
+
setRunFeedback((prev) => {
|
|
49
|
+
const next = { ...prev };
|
|
50
|
+
delete next[item.id];
|
|
51
|
+
return next;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
await qc.invalidateQueries({ queryKey: ["automationReviews"] });
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (reviews.isLoading) return <LoadingPanel title="Review inbox" />;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Card>
|
|
63
|
+
<CardHeader className="gap-3">
|
|
64
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
65
|
+
<div>
|
|
66
|
+
<CardTitle>Review inbox</CardTitle>
|
|
67
|
+
<CardDescription>Automation suggestions waiting for your decision. Run now executes without approving.</CardDescription>
|
|
68
|
+
</div>
|
|
69
|
+
{reviews.data ? (
|
|
70
|
+
<Badge variant={reviews.data.ok ? "success" : "warning"}>{reviews.data.ok ? "connected" : "unavailable"}</Badge>
|
|
71
|
+
) : null}
|
|
72
|
+
</div>
|
|
73
|
+
<div className="grid gap-2">
|
|
74
|
+
<Tabs
|
|
75
|
+
tabs={reviewStatusFilters}
|
|
76
|
+
value={statusFilter}
|
|
77
|
+
onChange={(id) => setStatusFilter(id as ReviewStatusFilter)}
|
|
78
|
+
/>
|
|
79
|
+
<Tabs
|
|
80
|
+
tabs={reviewSourceFilters}
|
|
81
|
+
value={sourceFilter}
|
|
82
|
+
onChange={(id) => setSourceFilter(id as ReviewSourceFilter)}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
</CardHeader>
|
|
86
|
+
<CardContent>
|
|
87
|
+
{reviews.isError || (reviews.data && !reviews.data.ok) ? (
|
|
88
|
+
<EmptyState
|
|
89
|
+
title="Could not load review inbox"
|
|
90
|
+
detail={reviews.data?.error || "The review queue is not available right now."}
|
|
91
|
+
/>
|
|
92
|
+
) : !items.length ? (
|
|
93
|
+
<EmptyState
|
|
94
|
+
title="Nothing to review"
|
|
95
|
+
detail={statusFilter === "snoozed" ? "Snoozed items will appear here until they are unsnoozed or become pending again." : "When automations opt into the review queue, new suggestions will appear here."}
|
|
96
|
+
/>
|
|
97
|
+
) : (
|
|
98
|
+
<div className="grid gap-3">
|
|
99
|
+
{items.map((item) => (
|
|
100
|
+
<ReviewCard
|
|
101
|
+
key={item.id}
|
|
102
|
+
item={item}
|
|
103
|
+
feedback={runFeedback[item.id]}
|
|
104
|
+
onAction={actOnReview}
|
|
105
|
+
/>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</CardContent>
|
|
110
|
+
</Card>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import type { ReviewItem, ReviewSourceFilter, ReviewStatusFilter } from "@/api/client";
|
|
3
|
+
import type { Badge } from "@/components/ui/badge";
|
|
4
|
+
|
|
5
|
+
export type ReviewAction = "approve" | "dismiss" | "snooze" | "unsnooze" | "run_now";
|
|
6
|
+
|
|
7
|
+
export const reviewStatusFilters: Array<{ id: ReviewStatusFilter; label: string }> = [
|
|
8
|
+
{ id: "pending", label: "Pending" },
|
|
9
|
+
{ id: "snoozed", label: "Snoozed" },
|
|
10
|
+
{ id: "all", label: "All" },
|
|
11
|
+
{ id: "approved", label: "Approved" },
|
|
12
|
+
{ id: "dismissed", label: "Dismissed" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const reviewSourceFilters: Array<{ id: ReviewSourceFilter; label: string }> = [
|
|
16
|
+
{ id: "all", label: "All sources" },
|
|
17
|
+
{ id: "workflow_run", label: "Workflow" },
|
|
18
|
+
{ id: "trigger", label: "Trigger" },
|
|
19
|
+
{ id: "kg_change_digest", label: "KG digest" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function reviewStatusVariant(status: string): React.ComponentProps<typeof Badge>["variant"] {
|
|
23
|
+
if (status === "pending") return "warning";
|
|
24
|
+
if (status === "snoozed") return "muted";
|
|
25
|
+
if (status === "approved") return "success";
|
|
26
|
+
if (status === "dismissed") return "danger";
|
|
27
|
+
return "muted";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function reviewSourceLabel(source?: string) {
|
|
31
|
+
if (source === "workflow_run") return "Workflow run";
|
|
32
|
+
if (source === "trigger") return "Trigger";
|
|
33
|
+
if (source === "kg_change_digest") return "KG digest";
|
|
34
|
+
return source || "Automation";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function reviewSourceDetail(provenance: Record<string, unknown>, source?: string) {
|
|
38
|
+
const detail = provenance.source_detail;
|
|
39
|
+
if (detail != null && String(detail).trim()) return String(detail);
|
|
40
|
+
const triggerId = provenance.trigger_id;
|
|
41
|
+
if (triggerId != null && String(triggerId).trim()) return String(triggerId);
|
|
42
|
+
return reviewSourceLabel(source);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function defaultSnoozeUntil() {
|
|
46
|
+
const until = new Date();
|
|
47
|
+
until.setDate(until.getDate() + 1);
|
|
48
|
+
return until.toISOString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatSnoozedUntil(value?: string | null) {
|
|
52
|
+
if (!value) return "Snoozed";
|
|
53
|
+
const date = new Date(value);
|
|
54
|
+
if (Number.isNaN(date.getTime())) return `Snoozed until ${value}`;
|
|
55
|
+
return `Snoozed until ${new Intl.DateTimeFormat(undefined, {
|
|
56
|
+
dateStyle: "medium",
|
|
57
|
+
timeStyle: "short",
|
|
58
|
+
}).format(date)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isActionableReview(item: ReviewItem) {
|
|
62
|
+
return item.effective_status === "pending" || item.effective_status === "snoozed";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function hasRunBefore(item: ReviewItem) {
|
|
66
|
+
const payload = item.payload || {};
|
|
67
|
+
const provenance = item.provenance || {};
|
|
68
|
+
return Boolean(payload.last_run_id || provenance.run_id);
|
|
69
|
+
}
|
package/frontend/src/i18n.ts
CHANGED
|
@@ -31,6 +31,10 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
31
31
|
"brain.empty.kicker": "내 오래가는 기억",
|
|
32
32
|
"brain.empty.title": "잊으면 안 되는 맥락부터 말해 주세요.",
|
|
33
33
|
"brain.empty.body": "문서, 대화, 프로젝트, 결정이 이 컴퓨터의 Brain에 쌓이고 나중에 주제와 관계로 다시 보입니다.",
|
|
34
|
+
"brain.empty.trail.label": "첫 Brain 흐름",
|
|
35
|
+
"brain.empty.trail.save": "1. 첫 기억 저장",
|
|
36
|
+
"brain.empty.trail.recall": "2. Brain Home에서 확인",
|
|
37
|
+
"brain.empty.trail.backup": "3. 백업으로 소유",
|
|
34
38
|
"brain.prompt.remember": "이 프로젝트 목표를 기억해줘: ",
|
|
35
39
|
"brain.prompt.know": "이 문서를 내 Brain에 넣고 요약해줘: ",
|
|
36
40
|
"brain.prompt.plan": "지난 결정들을 나중에 찾을 수 있게 정리해줘: ",
|
|
@@ -77,7 +81,7 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
77
81
|
"admin.body": "사용자, 로그, 보안, Brain 상태는 일반 사용자 화면과 분리됩니다.",
|
|
78
82
|
"flow.shell": "내 로컬 AI 브레인 만들기",
|
|
79
83
|
"flow.login.title": "내 AI 브레인의 주인을 정합니다.",
|
|
80
|
-
"flow.login.body": "Lattice AI는
|
|
84
|
+
"flow.login.body": "Lattice AI는 내 지식과 맥락을 이 컴퓨터에 보관하는 로컬 우선 AI 브레인입니다. 모델은 바꿀 수 있고, 외부 전송은 사용자가 선택할 때만 시작됩니다.",
|
|
81
85
|
"flow.name": "이름",
|
|
82
86
|
"flow.email": "이메일",
|
|
83
87
|
"flow.password": "비밀번호",
|
|
@@ -94,9 +98,9 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
94
98
|
"flow.promise.model.k": "교체 가능한 모델",
|
|
95
99
|
"flow.promise.model.v": "모델은 목소리이고, 자산은 Brain입니다.",
|
|
96
100
|
"flow.promise.ownership.k": "사용자 소유",
|
|
97
|
-
"flow.promise.ownership.v": "백업, 복원,
|
|
101
|
+
"flow.promise.ownership.v": "백업, 복원, 이동을 직접 결정합니다.",
|
|
98
102
|
"flow.analysis.title": "이 컴퓨터에서 가능한 경험을 확인합니다.",
|
|
99
|
-
"flow.analysis.body": "스펙 점수가 아니라, 이 Mac에서 어떤 로컬 AI 브레인 경험이 편한지 알려드립니다. 클라우드 모델은 선택 사항입니다.",
|
|
103
|
+
"flow.analysis.body": "스펙 점수가 아니라, 이 Mac에서 어떤 로컬 AI 브레인 경험이 편한지 알려드립니다. 확인은 로컬 상태를 읽는 것이고, 클라우드 모델은 선택 사항입니다.",
|
|
100
104
|
"flow.analysis.finding": "가장 편한 설정을 찾는 중...",
|
|
101
105
|
"flow.analysis.ready": "추천 모델을 바로 시작할 수 있게 준비했습니다.",
|
|
102
106
|
"flow.analysis.wait": "잠시만 기다리면 자동으로 정리됩니다.",
|
|
@@ -107,9 +111,10 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
107
111
|
"flow.recommend.primary": "추천대로 시작하기",
|
|
108
112
|
"flow.recommend.unsupported": "이 컴퓨터에서 추가 확인이 필요합니다",
|
|
109
113
|
"flow.recommend.back": "뒤로",
|
|
114
|
+
"flow.recommend.skip": "모델 없이 Brain 열기",
|
|
110
115
|
"flow.recommend.hint": "잘 모르겠다면 추천대로 시작하면 됩니다.",
|
|
111
116
|
"flow.install.title": "모델을 설치하고 시작합니다.",
|
|
112
|
-
"flow.install.body": "이 모델이 Brain의 로컬 목소리가 됩니다. 다운로드할 때만
|
|
117
|
+
"flow.install.body": "이 모델이 Brain의 로컬 목소리가 됩니다. 인터넷은 다운로드할 때만 필요하고, 실행과 기억은 이 컴퓨터에서 유지됩니다.",
|
|
113
118
|
"flow.install.wait": "Brain이 사용할 모델을 기다리고 있습니다.",
|
|
114
119
|
"flow.install.prepare": "Brain 준비 중입니다.",
|
|
115
120
|
"flow.install.done": "Brain이 준비되었습니다.",
|
|
@@ -144,6 +149,10 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
144
149
|
"brain.empty.kicker": "Durable memory",
|
|
145
150
|
"brain.empty.title": "Start with context that should not be forgotten.",
|
|
146
151
|
"brain.empty.body": "Documents, conversations, projects, and decisions accumulate in the Brain on this computer, then reappear as topics and relationships.",
|
|
152
|
+
"brain.empty.trail.label": "First Brain flow",
|
|
153
|
+
"brain.empty.trail.save": "1. Save first memory",
|
|
154
|
+
"brain.empty.trail.recall": "2. See it in Brain Home",
|
|
155
|
+
"brain.empty.trail.backup": "3. Own it with backup",
|
|
147
156
|
"brain.prompt.remember": "Remember this project goal: ",
|
|
148
157
|
"brain.prompt.know": "Put this document into my Brain and summarize it: ",
|
|
149
158
|
"brain.prompt.plan": "Organize past decisions so I can find them later: ",
|
|
@@ -190,7 +199,7 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
190
199
|
"admin.body": "Users, logs, security, and Brain health stay out of the normal user experience.",
|
|
191
200
|
"flow.shell": "Create your local AI Brain",
|
|
192
201
|
"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
|
|
202
|
+
"flow.login.body": "Lattice AI is a local-first Digital Brain that keeps your knowledge on this computer. Models can change; external transfer starts only when you choose it.",
|
|
194
203
|
"flow.name": "Name",
|
|
195
204
|
"flow.email": "Email",
|
|
196
205
|
"flow.password": "Password",
|
|
@@ -207,9 +216,9 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
207
216
|
"flow.promise.model.k": "Replaceable models",
|
|
208
217
|
"flow.promise.model.v": "The model is the voice; the Brain is the asset.",
|
|
209
218
|
"flow.promise.ownership.k": "User owned",
|
|
210
|
-
"flow.promise.ownership.v": "
|
|
219
|
+
"flow.promise.ownership.v": "You decide when to back up, restore, or move it.",
|
|
211
220
|
"flow.analysis.title": "Checking what this computer can do.",
|
|
212
|
-
"flow.analysis.body": "This is not a spec test. Lattice explains what
|
|
221
|
+
"flow.analysis.body": "This is not a spec test. Lattice reads local capability and explains what AI Brain experience this Mac can support. Cloud models remain optional.",
|
|
213
222
|
"flow.analysis.finding": "Finding the easiest setup...",
|
|
214
223
|
"flow.analysis.ready": "Your recommended model is ready to start.",
|
|
215
224
|
"flow.analysis.wait": "This will be summarized automatically in a moment.",
|
|
@@ -220,9 +229,10 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
220
229
|
"flow.recommend.primary": "Start with recommendation",
|
|
221
230
|
"flow.recommend.unsupported": "This computer needs one more check",
|
|
222
231
|
"flow.recommend.back": "Back",
|
|
232
|
+
"flow.recommend.skip": "Open Brain without a model",
|
|
223
233
|
"flow.recommend.hint": "If unsure, start with the recommendation.",
|
|
224
234
|
"flow.install.title": "Install the model and start.",
|
|
225
|
-
"flow.install.body": "This model becomes your Brain's local voice. Internet is needed only for download; execution
|
|
235
|
+
"flow.install.body": "This model becomes your Brain's local voice. Internet is needed only for download; execution and memory stay on this computer.",
|
|
226
236
|
"flow.install.wait": "Waiting for the model your Brain will use.",
|
|
227
237
|
"flow.install.prepare": "Preparing your Brain.",
|
|
228
238
|
"flow.install.done": "Your Brain is ready.",
|
|
@@ -2,30 +2,23 @@ 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
4
|
import { Bot, CalendarClock, GitBranch, PauseCircle, Play, ShieldCheck, Workflow } from "lucide-react";
|
|
5
|
-
import { latticeApi
|
|
6
|
-
import { ActionButton, DataPanel,
|
|
5
|
+
import { latticeApi } from "@/api/client";
|
|
6
|
+
import { ActionButton, DataPanel, EntityList, KeyValueList, ModeGate, OperationResult, StructuredView, Tabs } from "@/components/primitives";
|
|
7
7
|
import { Badge } from "@/components/ui/badge";
|
|
8
8
|
import { Button } from "@/components/ui/button";
|
|
9
9
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
10
10
|
import { Input } from "@/components/ui/input";
|
|
11
11
|
import { Textarea } from "@/components/ui/textarea";
|
|
12
|
+
import { ReviewInbox } from "@/features/review/ReviewInbox";
|
|
12
13
|
import { useAppStore } from "@/store/appStore";
|
|
13
14
|
import { asArray, shortId } from "@/lib/utils";
|
|
14
15
|
|
|
15
16
|
type ActTab = "agents" | "runs" | "workflows" | "hooks" | "tools";
|
|
16
17
|
type RunsSubTab = "runs" | "review";
|
|
17
|
-
type ReviewSourceFilter = "all" | "workflow_run" | "trigger" | "kg_change_digest";
|
|
18
18
|
|
|
19
19
|
const runsSubTabs: Array<{ id: RunsSubTab; label: string }> = [
|
|
20
20
|
{ id: "runs", label: "Runs" },
|
|
21
|
-
{ id: "review", label: "Review" },
|
|
22
|
-
];
|
|
23
|
-
|
|
24
|
-
const reviewSourceFilters: Array<{ id: ReviewSourceFilter; label: string }> = [
|
|
25
|
-
{ id: "all", label: "All" },
|
|
26
|
-
{ id: "workflow_run", label: "Workflow" },
|
|
27
|
-
{ id: "trigger", label: "Trigger" },
|
|
28
|
-
{ id: "kg_change_digest", label: "KG digest" },
|
|
21
|
+
{ id: "review", label: "Review Center" },
|
|
29
22
|
];
|
|
30
23
|
|
|
31
24
|
const tabs: Array<{ id: ActTab; label: string }> = [
|
|
@@ -150,7 +143,7 @@ function RunsPanel({ subTab, onSubTabChange }: { subTab: RunsSubTab; onSubTabCha
|
|
|
150
143
|
return (
|
|
151
144
|
<div className="space-y-4">
|
|
152
145
|
<Tabs tabs={runsSubTabs} value={subTab} onChange={(id) => onSubTabChange(id as RunsSubTab)} />
|
|
153
|
-
{subTab === "runs" ? <RunsListPanel /> : <
|
|
146
|
+
{subTab === "runs" ? <RunsListPanel /> : <ReviewInbox />}
|
|
154
147
|
</div>
|
|
155
148
|
);
|
|
156
149
|
}
|
|
@@ -198,171 +191,6 @@ function RunsListPanel() {
|
|
|
198
191
|
);
|
|
199
192
|
}
|
|
200
193
|
|
|
201
|
-
function reviewStatusVariant(status: string): React.ComponentProps<typeof Badge>["variant"] {
|
|
202
|
-
if (status === "pending") return "warning";
|
|
203
|
-
if (status === "snoozed") return "muted";
|
|
204
|
-
if (status === "approved") return "success";
|
|
205
|
-
if (status === "dismissed") return "danger";
|
|
206
|
-
return "muted";
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function reviewSourceLabel(source?: string) {
|
|
210
|
-
if (source === "workflow_run") return "Workflow run";
|
|
211
|
-
if (source === "trigger") return "Trigger";
|
|
212
|
-
if (source === "kg_change_digest") return "KG digest";
|
|
213
|
-
return source || "Automation";
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function reviewSourceDetail(provenance: Record<string, unknown>, source?: string) {
|
|
217
|
-
const detail = provenance.source_detail;
|
|
218
|
-
if (detail != null && String(detail).trim()) return String(detail);
|
|
219
|
-
const triggerId = provenance.trigger_id;
|
|
220
|
-
if (triggerId != null && String(triggerId).trim()) return String(triggerId);
|
|
221
|
-
return reviewSourceLabel(source);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function defaultSnoozeUntil() {
|
|
225
|
-
const until = new Date();
|
|
226
|
-
until.setDate(until.getDate() + 1);
|
|
227
|
-
return until.toISOString();
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function ReviewInboxPanel() {
|
|
231
|
-
const mode = useAppStore((state) => state.mode);
|
|
232
|
-
const qc = useQueryClient();
|
|
233
|
-
const [sourceFilter, setSourceFilter] = React.useState<ReviewSourceFilter>("all");
|
|
234
|
-
const [runFeedback, setRunFeedback] = React.useState<Record<string, string>>({});
|
|
235
|
-
const reviews = useQuery({
|
|
236
|
-
queryKey: ["automationReviews", sourceFilter],
|
|
237
|
-
queryFn: () => latticeApi.automationReviews({
|
|
238
|
-
status: "pending",
|
|
239
|
-
...(sourceFilter !== "all" ? { source: sourceFilter } : {}),
|
|
240
|
-
}),
|
|
241
|
-
});
|
|
242
|
-
const items = asArray<ReviewItem>((reviews.data?.data as { items?: ReviewItem[] })?.items);
|
|
243
|
-
const actionable = (item: ReviewItem) => item.effective_status === "pending" || item.effective_status === "snoozed";
|
|
244
|
-
|
|
245
|
-
const actOnReview = async (
|
|
246
|
-
item: ReviewItem,
|
|
247
|
-
action: "approve" | "dismiss" | "snooze" | "run_now",
|
|
248
|
-
hadRunBefore = false,
|
|
249
|
-
) => {
|
|
250
|
-
const call =
|
|
251
|
-
action === "approve" ? () => latticeApi.approveReviewItem(item.id) :
|
|
252
|
-
action === "dismiss" ? () => latticeApi.dismissReviewItem(item.id) :
|
|
253
|
-
action === "snooze" ? () => latticeApi.snoozeReviewItem(item.id, defaultSnoozeUntil()) :
|
|
254
|
-
() => latticeApi.runNowReviewItem(item.id);
|
|
255
|
-
const result = await call();
|
|
256
|
-
if (result.ok) {
|
|
257
|
-
if (action === "run_now") {
|
|
258
|
-
setRunFeedback((prev) => ({
|
|
259
|
-
...prev,
|
|
260
|
-
[item.id]: hadRunBefore ? "Regenerated" : "Executed",
|
|
261
|
-
}));
|
|
262
|
-
} else {
|
|
263
|
-
setRunFeedback((prev) => {
|
|
264
|
-
const next = { ...prev };
|
|
265
|
-
delete next[item.id];
|
|
266
|
-
return next;
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
await qc.invalidateQueries({ queryKey: ["automationReviews"] });
|
|
270
|
-
}
|
|
271
|
-
return result;
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
if (reviews.isLoading) return <LoadingPanel title="Review inbox" />;
|
|
275
|
-
|
|
276
|
-
return (
|
|
277
|
-
<Card>
|
|
278
|
-
<CardHeader className="gap-3">
|
|
279
|
-
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
280
|
-
<div>
|
|
281
|
-
<CardTitle>Review inbox</CardTitle>
|
|
282
|
-
<CardDescription>Automation suggestions waiting for your decision. Run now previews without approving.</CardDescription>
|
|
283
|
-
</div>
|
|
284
|
-
{reviews.data ? (
|
|
285
|
-
<Badge variant={reviews.data.ok ? "success" : "warning"}>{reviews.data.ok ? "connected" : "unavailable"}</Badge>
|
|
286
|
-
) : null}
|
|
287
|
-
</div>
|
|
288
|
-
<Tabs
|
|
289
|
-
tabs={reviewSourceFilters}
|
|
290
|
-
value={sourceFilter}
|
|
291
|
-
onChange={(id) => setSourceFilter(id as ReviewSourceFilter)}
|
|
292
|
-
/>
|
|
293
|
-
</CardHeader>
|
|
294
|
-
<CardContent>
|
|
295
|
-
{reviews.isError || (reviews.data && !reviews.data.ok) ? (
|
|
296
|
-
<EmptyState
|
|
297
|
-
title="Could not load review inbox"
|
|
298
|
-
detail={reviews.data?.error || "The review queue is not available right now."}
|
|
299
|
-
/>
|
|
300
|
-
) : !items.length ? (
|
|
301
|
-
<EmptyState
|
|
302
|
-
title="Nothing to review"
|
|
303
|
-
detail="When automations opt into the review queue, new suggestions will appear here."
|
|
304
|
-
/>
|
|
305
|
-
) : (
|
|
306
|
-
<div className="grid gap-3">
|
|
307
|
-
{items.map((item) => {
|
|
308
|
-
const provenance = (item.provenance || {}) as Record<string, unknown>;
|
|
309
|
-
const payload = (item.payload || {}) as Record<string, unknown>;
|
|
310
|
-
const hadRunBefore = Boolean(payload.last_run_id || provenance.run_id);
|
|
311
|
-
const feedback = runFeedback[item.id];
|
|
312
|
-
return (
|
|
313
|
-
<div key={item.id} className="rounded-lg border border-border bg-background/55 p-4">
|
|
314
|
-
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
315
|
-
<div className="min-w-0 flex-1">
|
|
316
|
-
<div className="font-medium">{item.title}</div>
|
|
317
|
-
{item.summary ? <p className="mt-1 text-sm text-muted-foreground">{item.summary}</p> : null}
|
|
318
|
-
</div>
|
|
319
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
320
|
-
<Badge variant="muted">{reviewSourceLabel(item.source)}</Badge>
|
|
321
|
-
<Badge variant={reviewStatusVariant(item.effective_status)}>{item.effective_status}</Badge>
|
|
322
|
-
</div>
|
|
323
|
-
</div>
|
|
324
|
-
{mode !== "basic" ? (
|
|
325
|
-
<div className="mt-3">
|
|
326
|
-
<KeyValueList
|
|
327
|
-
data={{
|
|
328
|
-
workflow: provenance.workflow_id,
|
|
329
|
-
trigger: provenance.trigger_id,
|
|
330
|
-
run: payload.last_run_id || provenance.run_id,
|
|
331
|
-
source_detail: reviewSourceDetail(provenance, item.source),
|
|
332
|
-
snoozed_until: item.snoozed_until,
|
|
333
|
-
created_at: item.created_at,
|
|
334
|
-
updated_at: item.updated_at,
|
|
335
|
-
}}
|
|
336
|
-
limit={8}
|
|
337
|
-
/>
|
|
338
|
-
</div>
|
|
339
|
-
) : null}
|
|
340
|
-
{actionable(item) ? (
|
|
341
|
-
<div className="mt-4 flex flex-wrap gap-2">
|
|
342
|
-
<ActionButton
|
|
343
|
-
label="Run now"
|
|
344
|
-
successLabel={hadRunBefore ? "Regenerated" : "Executed"}
|
|
345
|
-
action={() => actOnReview(item, "run_now", hadRunBefore)}
|
|
346
|
-
invalidate={[]}
|
|
347
|
-
/>
|
|
348
|
-
<ActionButton label="Approve" action={() => actOnReview(item, "approve")} invalidate={[]} />
|
|
349
|
-
<ActionButton label="Snooze" action={() => actOnReview(item, "snooze")} invalidate={[]} />
|
|
350
|
-
<ActionButton label="Dismiss" action={() => actOnReview(item, "dismiss")} invalidate={[]} variant="destructive" />
|
|
351
|
-
</div>
|
|
352
|
-
) : null}
|
|
353
|
-
{feedback ? (
|
|
354
|
-
<p className="mt-2 text-xs text-emerald-300">{feedback} — item stays open until you approve or dismiss.</p>
|
|
355
|
-
) : null}
|
|
356
|
-
</div>
|
|
357
|
-
);
|
|
358
|
-
})}
|
|
359
|
-
</div>
|
|
360
|
-
)}
|
|
361
|
-
</CardContent>
|
|
362
|
-
</Card>
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
194
|
function RunList({ runs, kind }: { runs: Array<Record<string, unknown>>; kind: "agent" | "workflow" }) {
|
|
367
195
|
if (!runs.length) return <EntityList items={[]} />;
|
|
368
196
|
return (
|
package/frontend/src/routes.ts
CHANGED
|
@@ -32,6 +32,7 @@ export const routeAliases: Record<string, { primary: PrimaryRoute; tab?: string
|
|
|
32
32
|
agents: { primary: "act", tab: "agents" },
|
|
33
33
|
runs: { primary: "act", tab: "runs" },
|
|
34
34
|
review: { primary: "act", tab: "review" },
|
|
35
|
+
"review-center": { primary: "act", tab: "review" },
|
|
35
36
|
workflows: { primary: "act", tab: "workflows" },
|
|
36
37
|
planning: { primary: "act", tab: "agents" },
|
|
37
38
|
hooks: { primary: "act", tab: "hooks" },
|
package/frontend/src/styles.css
CHANGED
|
@@ -2774,6 +2774,26 @@ body {
|
|
|
2774
2774
|
transform: translateY(-1px);
|
|
2775
2775
|
}
|
|
2776
2776
|
|
|
2777
|
+
.mind-empty-trail {
|
|
2778
|
+
display: flex;
|
|
2779
|
+
flex-wrap: wrap;
|
|
2780
|
+
justify-content: center;
|
|
2781
|
+
gap: 0.45rem;
|
|
2782
|
+
margin: 1rem auto 0;
|
|
2783
|
+
max-width: 40rem;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
.mind-empty-trail span {
|
|
2787
|
+
border: 1px solid hsl(var(--border) / 0.55);
|
|
2788
|
+
border-radius: 999px;
|
|
2789
|
+
background: hsl(var(--surface-glass));
|
|
2790
|
+
color: hsl(var(--fg-muted));
|
|
2791
|
+
padding: 0.34rem 0.62rem;
|
|
2792
|
+
font-size: 0.72rem;
|
|
2793
|
+
font-weight: 720;
|
|
2794
|
+
line-height: 1.15;
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2777
2797
|
.mind-empty .icon {
|
|
2778
2798
|
opacity: 0.5;
|
|
2779
2799
|
margin-bottom: 0.8rem;
|
|
@@ -19,7 +19,7 @@ from datetime import datetime
|
|
|
19
19
|
from typing import Any, Callable, Dict, List, Optional
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
MULTI_AGENT_VERSION = "
|
|
22
|
+
MULTI_AGENT_VERSION = "6.1.0"
|
|
23
23
|
|
|
24
24
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
25
25
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED