ltcai 5.5.0 → 6.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/README.md +43 -24
- package/docs/CHANGELOG.md +69 -0
- package/frontend/openapi.json +716 -3
- package/frontend/src/api/client.ts +119 -2
- package/frontend/src/api/openapi.ts +621 -4
- package/frontend/src/components/FirstRunGuide.tsx +3 -3
- package/frontend/src/features/review/ReviewCard.tsx +91 -0
- package/frontend/src/features/review/ReviewInbox.tsx +112 -0
- package/frontend/src/features/review/reviewHelpers.ts +69 -0
- package/frontend/src/i18n.ts +8 -8
- package/frontend/src/pages/Act.tsx +28 -3
- package/frontend/src/routes.ts +2 -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/review_queue.py +162 -0
- package/latticeai/app_factory.py +235 -456
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +86 -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/review_queue.py +271 -0
- package/latticeai/services/run_executor.py +33 -0
- package/latticeai/services/triggers.py +30 -1
- 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-D2zafMYb.js +16 -0
- package/static/app/assets/index-D2zafMYb.js.map +1 -0
- package/static/app/assets/index-xRn29gI8.css +2 -0
- package/static/app/index.html +2 -2
- package/static/app/assets/index-C7vzwUjU.js +0 -16
- package/static/app/assets/index-C7vzwUjU.js.map +0 -1
- package/static/app/assets/index-HN4f2wbe.css +0 -2
|
@@ -53,10 +53,10 @@ export function FirstRunGuide() {
|
|
|
53
53
|
<section className="arrival-panel" aria-label="First 10 minutes">
|
|
54
54
|
<div className="arrival-copy">
|
|
55
55
|
<div className="page-kicker"><CheckCircle2 className="h-4 w-4" /> First 10 minutes</div>
|
|
56
|
-
<h2>
|
|
56
|
+
<h2>Start locally, with clear consent at each step.</h2>
|
|
57
57
|
<p>
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
Create the local Brain first, choose when to download a model, then add durable knowledge when you are ready.
|
|
59
|
+
Nothing needs cloud access unless you explicitly choose it.
|
|
60
60
|
</p>
|
|
61
61
|
<div className="arrival-actions">
|
|
62
62
|
<Button onClick={() => go(nextStep.action)}>{nextStep.done ? "Open relationships" : `Continue: ${nextStep.label}`}</Button>
|
|
@@ -0,0 +1,91 @@
|
|
|
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 flex flex-wrap gap-2">
|
|
75
|
+
<ActionButton
|
|
76
|
+
label="Run now"
|
|
77
|
+
successLabel={hadRun ? "Regenerated" : "Executed"}
|
|
78
|
+
action={() => onAction(item, "run_now", hadRun)}
|
|
79
|
+
invalidate={[]}
|
|
80
|
+
/>
|
|
81
|
+
<ActionButton label="Approve" action={() => onAction(item, "approve")} invalidate={[]} />
|
|
82
|
+
{!snoozed ? <ActionButton label="Snooze 1 day" action={() => onAction(item, "snooze")} invalidate={[]} /> : null}
|
|
83
|
+
<ActionButton label="Dismiss" action={() => onAction(item, "dismiss")} invalidate={[]} variant="destructive" />
|
|
84
|
+
</div>
|
|
85
|
+
) : null}
|
|
86
|
+
{feedback ? (
|
|
87
|
+
<p className="mt-2 text-xs text-emerald-300">{feedback} - item stays open until you approve or dismiss.</p>
|
|
88
|
+
) : null}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -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
|
@@ -77,7 +77,7 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
77
77
|
"admin.body": "사용자, 로그, 보안, Brain 상태는 일반 사용자 화면과 분리됩니다.",
|
|
78
78
|
"flow.shell": "내 로컬 AI 브레인 만들기",
|
|
79
79
|
"flow.login.title": "내 AI 브레인의 주인을 정합니다.",
|
|
80
|
-
"flow.login.body": "Lattice AI는
|
|
80
|
+
"flow.login.body": "Lattice AI는 내 지식과 맥락을 이 컴퓨터에 보관하는 로컬 우선 AI 브레인입니다. 모델은 바꿀 수 있고, 외부 전송은 사용자가 선택할 때만 시작됩니다.",
|
|
81
81
|
"flow.name": "이름",
|
|
82
82
|
"flow.email": "이메일",
|
|
83
83
|
"flow.password": "비밀번호",
|
|
@@ -94,9 +94,9 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
94
94
|
"flow.promise.model.k": "교체 가능한 모델",
|
|
95
95
|
"flow.promise.model.v": "모델은 목소리이고, 자산은 Brain입니다.",
|
|
96
96
|
"flow.promise.ownership.k": "사용자 소유",
|
|
97
|
-
"flow.promise.ownership.v": "백업, 복원,
|
|
97
|
+
"flow.promise.ownership.v": "백업, 복원, 이동을 직접 결정합니다.",
|
|
98
98
|
"flow.analysis.title": "이 컴퓨터에서 가능한 경험을 확인합니다.",
|
|
99
|
-
"flow.analysis.body": "스펙 점수가 아니라, 이 Mac에서 어떤 로컬 AI 브레인 경험이 편한지 알려드립니다. 클라우드 모델은 선택 사항입니다.",
|
|
99
|
+
"flow.analysis.body": "스펙 점수가 아니라, 이 Mac에서 어떤 로컬 AI 브레인 경험이 편한지 알려드립니다. 확인은 로컬 상태를 읽는 것이고, 클라우드 모델은 선택 사항입니다.",
|
|
100
100
|
"flow.analysis.finding": "가장 편한 설정을 찾는 중...",
|
|
101
101
|
"flow.analysis.ready": "추천 모델을 바로 시작할 수 있게 준비했습니다.",
|
|
102
102
|
"flow.analysis.wait": "잠시만 기다리면 자동으로 정리됩니다.",
|
|
@@ -109,7 +109,7 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
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이 준비되었습니다.",
|
|
@@ -190,7 +190,7 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
190
190
|
"admin.body": "Users, logs, security, and Brain health stay out of the normal user experience.",
|
|
191
191
|
"flow.shell": "Create your local AI Brain",
|
|
192
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
|
|
193
|
+
"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
194
|
"flow.name": "Name",
|
|
195
195
|
"flow.email": "Email",
|
|
196
196
|
"flow.password": "Password",
|
|
@@ -207,9 +207,9 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
207
207
|
"flow.promise.model.k": "Replaceable models",
|
|
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
|
-
"flow.promise.ownership.v": "
|
|
210
|
+
"flow.promise.ownership.v": "You decide when to back up, restore, or move it.",
|
|
211
211
|
"flow.analysis.title": "Checking what this computer can do.",
|
|
212
|
-
"flow.analysis.body": "This is not a spec test. Lattice explains what
|
|
212
|
+
"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
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.",
|
|
@@ -222,7 +222,7 @@ export const COPY: Record<Language, TextMap> = {
|
|
|
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. Internet is needed only for download; execution
|
|
225
|
+
"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
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.",
|
|
@@ -9,10 +9,17 @@ 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";
|
|
17
|
+
type RunsSubTab = "runs" | "review";
|
|
18
|
+
|
|
19
|
+
const runsSubTabs: Array<{ id: RunsSubTab; label: string }> = [
|
|
20
|
+
{ id: "runs", label: "Runs" },
|
|
21
|
+
{ id: "review", label: "Review Center" },
|
|
22
|
+
];
|
|
16
23
|
|
|
17
24
|
const tabs: Array<{ id: ActTab; label: string }> = [
|
|
18
25
|
{ id: "agents", label: "Goals" },
|
|
@@ -24,8 +31,17 @@ const tabs: Array<{ id: ActTab; label: string }> = [
|
|
|
24
31
|
|
|
25
32
|
export function ActPage({ initialTab }: { initialTab?: string }) {
|
|
26
33
|
const mode = useAppStore((state) => state.mode);
|
|
27
|
-
const [tab, setTab] = React.useState<ActTab>((
|
|
34
|
+
const [tab, setTab] = React.useState<ActTab>(() => {
|
|
35
|
+
if (initialTab === "review") return "runs";
|
|
36
|
+
return (initialTab as ActTab) || "agents";
|
|
37
|
+
});
|
|
38
|
+
const [runsSubTab, setRunsSubTab] = React.useState<RunsSubTab>(initialTab === "review" ? "review" : "runs");
|
|
28
39
|
React.useEffect(() => {
|
|
40
|
+
if (initialTab === "review") {
|
|
41
|
+
setTab("runs");
|
|
42
|
+
setRunsSubTab("review");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
29
45
|
if (tabs.some((item) => item.id === initialTab)) setTab(initialTab as ActTab);
|
|
30
46
|
}, [initialTab]);
|
|
31
47
|
return (
|
|
@@ -37,7 +53,7 @@ export function ActPage({ initialTab }: { initialTab?: string }) {
|
|
|
37
53
|
</header>
|
|
38
54
|
<Tabs tabs={tabs.map((item) => mode === "basic" ? item : item.id === "hooks" ? { ...item, label: "Hooks" } : item.id === "tools" ? { ...item, label: "Tools" } : item)} value={tab} onChange={(id) => setTab(id as ActTab)} />
|
|
39
55
|
{tab === "agents" ? <AgentsPanel /> : null}
|
|
40
|
-
{tab === "runs" ? <RunsPanel /> : null}
|
|
56
|
+
{tab === "runs" ? <RunsPanel subTab={runsSubTab} onSubTabChange={setRunsSubTab} /> : null}
|
|
41
57
|
{tab === "workflows" ? <WorkflowsPanel /> : null}
|
|
42
58
|
{tab === "hooks" ? <HooksPanel /> : null}
|
|
43
59
|
{tab === "tools" ? <ToolsPanel /> : null}
|
|
@@ -123,7 +139,16 @@ function AgentsPanel() {
|
|
|
123
139
|
);
|
|
124
140
|
}
|
|
125
141
|
|
|
126
|
-
function RunsPanel() {
|
|
142
|
+
function RunsPanel({ subTab, onSubTabChange }: { subTab: RunsSubTab; onSubTabChange: (tab: RunsSubTab) => void }) {
|
|
143
|
+
return (
|
|
144
|
+
<div className="space-y-4">
|
|
145
|
+
<Tabs tabs={runsSubTabs} value={subTab} onChange={(id) => onSubTabChange(id as RunsSubTab)} />
|
|
146
|
+
{subTab === "runs" ? <RunsListPanel /> : <ReviewInbox />}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function RunsListPanel() {
|
|
127
152
|
const mode = useAppStore((state) => state.mode);
|
|
128
153
|
const runtime = useQuery({ queryKey: ["agentRuntime"], queryFn: latticeApi.agentRuntime });
|
|
129
154
|
const workflows = useQuery({ queryKey: ["workflowRuns"], queryFn: latticeApi.workflowRuns });
|
package/frontend/src/routes.ts
CHANGED
|
@@ -31,6 +31,8 @@ export const routeAliases: Record<string, { primary: PrimaryRoute; tab?: string
|
|
|
31
31
|
"my-computer": { primary: "capture", tab: "local" },
|
|
32
32
|
agents: { primary: "act", tab: "agents" },
|
|
33
33
|
runs: { primary: "act", tab: "runs" },
|
|
34
|
+
review: { primary: "act", tab: "review" },
|
|
35
|
+
"review-center": { primary: "act", tab: "review" },
|
|
34
36
|
workflows: { primary: "act", tab: "workflows" },
|
|
35
37
|
planning: { primary: "act", tab: "agents" },
|
|
36
38
|
hooks: { primary: "act", tab: "hooks" },
|
|
@@ -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.0.0"
|
|
23
23
|
|
|
24
24
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
25
25
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Brain Review Queue API router (5.6.0).
|
|
2
|
+
|
|
3
|
+
The suggestion inbox the /app Review view drives. Follows the existing
|
|
4
|
+
auth/workspace dependency pattern (``require_user`` + ``gate_read``/``gate_write``)
|
|
5
|
+
and exposes explicit response models so the OpenAPI types are usable from the
|
|
6
|
+
frontend without massaging.
|
|
7
|
+
|
|
8
|
+
Action semantics live in :class:`~latticeai.services.review_queue.ReviewQueueService`:
|
|
9
|
+
|
|
10
|
+
* ``approve`` / ``dismiss`` / ``snooze`` / ``unsnooze`` are status transitions;
|
|
11
|
+
an illegal transition returns **409**.
|
|
12
|
+
* ``run_now`` previews/regenerates without changing status (back-links the run).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
from latticeai.services.review_queue import InvalidReviewTransition, ReviewQueueService
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReviewItem(BaseModel):
|
|
26
|
+
id: str
|
|
27
|
+
status: str
|
|
28
|
+
effective_status: str
|
|
29
|
+
title: str
|
|
30
|
+
summary: str = ""
|
|
31
|
+
source: str = "workflow_run"
|
|
32
|
+
kind: str = "suggestion"
|
|
33
|
+
payload: Dict[str, Any] = Field(default_factory=dict)
|
|
34
|
+
provenance: Dict[str, Any] = Field(default_factory=dict)
|
|
35
|
+
snoozed_until: Optional[str] = None
|
|
36
|
+
user_email: Optional[str] = None
|
|
37
|
+
workspace_id: Optional[str] = None
|
|
38
|
+
created_at: Optional[str] = None
|
|
39
|
+
updated_at: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ReviewItemList(BaseModel):
|
|
43
|
+
items: List[ReviewItem] = Field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CreateReviewItemRequest(BaseModel):
|
|
47
|
+
title: str
|
|
48
|
+
summary: str = ""
|
|
49
|
+
source: str = "workflow_run"
|
|
50
|
+
kind: str = "suggestion"
|
|
51
|
+
payload: Dict[str, Any] = Field(default_factory=dict)
|
|
52
|
+
provenance: Dict[str, Any] = Field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SnoozeRequest(BaseModel):
|
|
56
|
+
until: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_review_queue_router(
|
|
60
|
+
*,
|
|
61
|
+
service: ReviewQueueService,
|
|
62
|
+
require_user: Callable[[Request], str],
|
|
63
|
+
gate_read: Callable[[Request], Optional[str]],
|
|
64
|
+
gate_write: Callable[[Request], Optional[str]],
|
|
65
|
+
run_review_item: Callable[..., Any],
|
|
66
|
+
append_audit_event: Callable[..., None],
|
|
67
|
+
) -> APIRouter:
|
|
68
|
+
router = APIRouter()
|
|
69
|
+
|
|
70
|
+
@router.get("/automation/reviews", response_model=ReviewItemList)
|
|
71
|
+
async def list_items(
|
|
72
|
+
request: Request, status: Optional[str] = None, source: Optional[str] = None,
|
|
73
|
+
):
|
|
74
|
+
user = require_user(request)
|
|
75
|
+
scope = gate_read(request)
|
|
76
|
+
return service.list(workspace_id=scope, user_email=user, status=status, source=source)
|
|
77
|
+
|
|
78
|
+
@router.post("/automation/reviews", response_model=ReviewItem)
|
|
79
|
+
async def create_item(req: CreateReviewItemRequest, request: Request):
|
|
80
|
+
user = require_user(request)
|
|
81
|
+
scope = gate_write(request)
|
|
82
|
+
try:
|
|
83
|
+
item = service.create(
|
|
84
|
+
title=req.title,
|
|
85
|
+
summary=req.summary,
|
|
86
|
+
source=req.source,
|
|
87
|
+
kind=req.kind,
|
|
88
|
+
payload=req.payload,
|
|
89
|
+
provenance=req.provenance,
|
|
90
|
+
user_email=user,
|
|
91
|
+
workspace_id=scope,
|
|
92
|
+
)
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
95
|
+
append_audit_event("review_item_created", user_email=user, item_id=item["id"])
|
|
96
|
+
return item
|
|
97
|
+
|
|
98
|
+
@router.get("/automation/reviews/{item_id}", response_model=ReviewItem)
|
|
99
|
+
async def get_item(item_id: str, request: Request):
|
|
100
|
+
require_user(request)
|
|
101
|
+
scope = gate_read(request)
|
|
102
|
+
try:
|
|
103
|
+
return service.get(item_id, workspace_id=scope)
|
|
104
|
+
except FileNotFoundError as exc:
|
|
105
|
+
raise HTTPException(status_code=404, detail="review item not found") from exc
|
|
106
|
+
|
|
107
|
+
@router.post("/automation/reviews/{item_id}/approve", response_model=ReviewItem)
|
|
108
|
+
async def approve_item(item_id: str, request: Request):
|
|
109
|
+
return _act(request, item_id, "approve")
|
|
110
|
+
|
|
111
|
+
@router.post("/automation/reviews/{item_id}/dismiss", response_model=ReviewItem)
|
|
112
|
+
async def dismiss_item(item_id: str, request: Request):
|
|
113
|
+
return _act(request, item_id, "dismiss")
|
|
114
|
+
|
|
115
|
+
@router.post("/automation/reviews/{item_id}/snooze", response_model=ReviewItem)
|
|
116
|
+
async def snooze_item(item_id: str, req: SnoozeRequest, request: Request):
|
|
117
|
+
user = require_user(request)
|
|
118
|
+
scope = gate_write(request)
|
|
119
|
+
try:
|
|
120
|
+
item = service.snooze(item_id, until=req.until, workspace_id=scope)
|
|
121
|
+
except FileNotFoundError as exc:
|
|
122
|
+
raise HTTPException(status_code=404, detail="review item not found") from exc
|
|
123
|
+
except InvalidReviewTransition as exc:
|
|
124
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
125
|
+
append_audit_event("review_item_snooze", user_email=user, item_id=item_id)
|
|
126
|
+
return item
|
|
127
|
+
|
|
128
|
+
@router.post("/automation/reviews/{item_id}/unsnooze", response_model=ReviewItem)
|
|
129
|
+
async def unsnooze_item(item_id: str, request: Request):
|
|
130
|
+
return _act(request, item_id, "unsnooze")
|
|
131
|
+
|
|
132
|
+
@router.post("/automation/reviews/{item_id}/run_now", response_model=ReviewItem)
|
|
133
|
+
async def run_now_item(item_id: str, request: Request):
|
|
134
|
+
user = require_user(request)
|
|
135
|
+
scope = gate_write(request)
|
|
136
|
+
try:
|
|
137
|
+
item = service.run_now(
|
|
138
|
+
item_id,
|
|
139
|
+
runner=lambda stored: run_review_item(stored, user_email=user, scope=scope),
|
|
140
|
+
workspace_id=scope,
|
|
141
|
+
)
|
|
142
|
+
except FileNotFoundError as exc:
|
|
143
|
+
raise HTTPException(status_code=404, detail="review item not found") from exc
|
|
144
|
+
except InvalidReviewTransition as exc:
|
|
145
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
146
|
+
append_audit_event("review_item_run_now", user_email=user, item_id=item_id)
|
|
147
|
+
return item
|
|
148
|
+
|
|
149
|
+
def _act(request: Request, item_id: str, action: str) -> Dict[str, Any]:
|
|
150
|
+
user = require_user(request)
|
|
151
|
+
scope = gate_write(request)
|
|
152
|
+
fn = getattr(service, action)
|
|
153
|
+
try:
|
|
154
|
+
item = fn(item_id, workspace_id=scope)
|
|
155
|
+
except FileNotFoundError as exc:
|
|
156
|
+
raise HTTPException(status_code=404, detail="review item not found") from exc
|
|
157
|
+
except InvalidReviewTransition as exc:
|
|
158
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
159
|
+
append_audit_event(f"review_item_{action}", user_email=user, item_id=item_id)
|
|
160
|
+
return item
|
|
161
|
+
|
|
162
|
+
return router
|