@saadnvd1/agent-os 0.3.761 → 0.3.762
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/app/api/artifacts/[id]/route.ts +13 -4
- package/app/api/artifacts/[id]/versions/route.ts +16 -0
- package/app/api/sessions/[id]/artifacts/route.ts +9 -1
- package/components/Artifacts/ArtifactActions.tsx +308 -0
- package/components/Artifacts/ArtifactFullscreen.tsx +58 -0
- package/components/Artifacts/ArtifactPreview.tsx +141 -18
- package/components/Artifacts/ArtifactRow.tsx +5 -0
- package/components/Artifacts/ArtifactsPanel.tsx +2 -0
- package/components/Artifacts/VersionSwitcher.tsx +216 -0
- package/components/Chat/Artifact.tsx +129 -89
- package/components/Share/DownloadMenu.tsx +63 -38
- package/components/Share/ShareDialog.tsx +2 -0
- package/data/session-artifacts/keys.ts +3 -0
- package/data/session-artifacts/queries.ts +25 -0
- package/lib/artifacts/actions.ts +75 -0
- package/lib/artifacts/store.ts +105 -10
- package/lib/artifacts/tools.ts +29 -6
- package/lib/artifacts/versions.ts +73 -0
- package/lib/chat/events.ts +4 -0
- package/lib/chat/worker/host.ts +2 -0
- package/lib/db/migrations/20261029T1200_artifact_versions.ts +28 -0
- package/lib/security/demo.ts +1 -0
- package/lib/session-artifacts/collect.ts +28 -6
- package/lib/session-artifacts/derive.ts +3 -1
- package/lib/session-artifacts/types.ts +9 -0
- package/package.json +1 -1
- package/stores/shareUi.ts +6 -2
|
@@ -1,112 +1,152 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import {
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { History } from "lucide-react";
|
|
5
5
|
import type { ChatItem } from "@/lib/chat/events";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { pageActions } from "@/lib/artifacts/actions";
|
|
7
|
+
import { useArtifactVersions } from "@/data/session-artifacts";
|
|
8
|
+
import { useViewport } from "@/hooks/useViewport";
|
|
9
|
+
import { cn } from "@/lib/utils";
|
|
10
|
+
import {
|
|
11
|
+
DropdownMenu,
|
|
12
|
+
DropdownMenuContent,
|
|
13
|
+
DropdownMenuItem,
|
|
14
|
+
DropdownMenuTrigger,
|
|
15
|
+
} from "@/components/ui/dropdown-menu";
|
|
16
|
+
import { ArtifactActions } from "@/components/Artifacts/ArtifactActions";
|
|
17
|
+
import { ArtifactFullscreen } from "@/components/Artifacts/ArtifactFullscreen";
|
|
18
|
+
import { versionDetail } from "@/components/Artifacts/VersionSwitcher";
|
|
19
|
+
import { ArtifactFrame } from "./ArtifactFrame";
|
|
9
20
|
|
|
10
21
|
type ArtifactItem = Extract<ChatItem, { kind: "artifact" }>;
|
|
11
22
|
|
|
12
|
-
|
|
13
|
-
|
|
23
|
+
// Until the server answers: no PDF, which it may not be able to print.
|
|
24
|
+
const FALLBACK_ACTIONS = pageActions({ pdf: false });
|
|
14
25
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
onFull,
|
|
18
|
-
}: {
|
|
19
|
-
item: ArtifactItem;
|
|
20
|
-
onFull?: () => void;
|
|
21
|
-
}) {
|
|
22
|
-
return (
|
|
23
|
-
<div className="flex shrink-0 items-center">
|
|
24
|
-
{onFull && (
|
|
25
|
-
<button
|
|
26
|
-
type="button"
|
|
27
|
-
onClick={onFull}
|
|
28
|
-
className={ICON_BUTTON}
|
|
29
|
-
aria-label="Full screen"
|
|
30
|
-
title="Full screen"
|
|
31
|
-
>
|
|
32
|
-
<Maximize2 className="h-4 w-4" />
|
|
33
|
-
</button>
|
|
34
|
-
)}
|
|
35
|
-
<button
|
|
36
|
-
type="button"
|
|
37
|
-
onClick={() =>
|
|
38
|
-
shareUiActions.open({
|
|
39
|
-
kind: "artifact",
|
|
40
|
-
artifactId: item.artifactId,
|
|
41
|
-
title: item.title,
|
|
42
|
-
})
|
|
43
|
-
}
|
|
44
|
-
className={ICON_BUTTON}
|
|
45
|
-
aria-label="Share"
|
|
46
|
-
title="Share"
|
|
47
|
-
>
|
|
48
|
-
<Share2 className="h-4 w-4" />
|
|
49
|
-
</button>
|
|
50
|
-
<a
|
|
51
|
-
href={artifactUrl(item.artifactId)}
|
|
52
|
-
target="_blank"
|
|
53
|
-
rel="noopener noreferrer"
|
|
54
|
-
className={ICON_BUTTON}
|
|
55
|
-
aria-label="Open in new tab"
|
|
56
|
-
title="Open in new tab"
|
|
57
|
-
>
|
|
58
|
-
<ExternalLink className="h-4 w-4" />
|
|
59
|
-
</a>
|
|
60
|
-
</div>
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// A page the agent showed with html_render, live, in its own card.
|
|
26
|
+
// A page the agent showed with html_render, live, in its own card. It shows
|
|
27
|
+
// the version this turn made; a later one is a tap away.
|
|
65
28
|
export function ArtifactCard({ item }: { item: ArtifactItem }) {
|
|
66
29
|
const [full, setFull] = useState(false);
|
|
30
|
+
const [shownId, setShownId] = useState(item.artifactId);
|
|
31
|
+
const { isMobile } = useViewport();
|
|
32
|
+
const pageId = item.pageId ?? item.artifactId;
|
|
33
|
+
const { data, refetch } = useArtifactVersions(pageId, item.artifactId);
|
|
34
|
+
// A newer card for the same page: what's cached predates it.
|
|
35
|
+
const stale = !!data && !data.versions.some((v) => v.id === item.artifactId);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (stale) void refetch();
|
|
38
|
+
}, [stale, refetch]);
|
|
39
|
+
|
|
40
|
+
const versions = data?.versions ?? [];
|
|
41
|
+
const shown = versions.find((v) => v.id === shownId);
|
|
42
|
+
const latest = versions[versions.length - 1];
|
|
43
|
+
const many = versions.length > 1;
|
|
44
|
+
const title = shown?.title ?? item.title;
|
|
45
|
+
const actions = (
|
|
46
|
+
<ArtifactActions
|
|
47
|
+
artifactId={shownId}
|
|
48
|
+
title={title}
|
|
49
|
+
version={many ? shown?.version : undefined}
|
|
50
|
+
actions={data?.actions ?? FALLBACK_ACTIONS}
|
|
51
|
+
variant="icons"
|
|
52
|
+
onFull={() => setFull(true)}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
|
|
67
56
|
return (
|
|
68
57
|
<div className="bg-foreground/[0.03] overflow-hidden rounded-xl shadow-sm">
|
|
69
58
|
<div className="flex items-center gap-2 py-0.5 pr-1 pl-3.5">
|
|
70
59
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
|
71
|
-
{
|
|
60
|
+
{title}
|
|
72
61
|
</span>
|
|
73
|
-
|
|
62
|
+
{many && shown && (
|
|
63
|
+
<DropdownMenu>
|
|
64
|
+
<DropdownMenuTrigger asChild>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
className={cn(
|
|
68
|
+
"inline-flex h-6 shrink-0 items-center rounded-full px-2 text-[11.5px]",
|
|
69
|
+
shown.id === latest.id
|
|
70
|
+
? "bg-primary/15 text-primary"
|
|
71
|
+
: "bg-muted text-muted-foreground"
|
|
72
|
+
)}
|
|
73
|
+
aria-label="Versions"
|
|
74
|
+
>
|
|
75
|
+
{shown.id === latest.id
|
|
76
|
+
? `v${shown.version} · latest`
|
|
77
|
+
: isMobile
|
|
78
|
+
? `v${shown.version}/${versions.length}`
|
|
79
|
+
: `v${shown.version} of ${versions.length}`}
|
|
80
|
+
</button>
|
|
81
|
+
</DropdownMenuTrigger>
|
|
82
|
+
<DropdownMenuContent align="end" className="w-80 max-w-[90vw]">
|
|
83
|
+
{[...versions].reverse().map((v) => (
|
|
84
|
+
<DropdownMenuItem
|
|
85
|
+
key={v.id}
|
|
86
|
+
className="min-h-11 flex-col items-start gap-0"
|
|
87
|
+
onSelect={() => setShownId(v.id)}
|
|
88
|
+
>
|
|
89
|
+
<span className="text-[13px]">
|
|
90
|
+
v{v.version}
|
|
91
|
+
{v.note ? ` · ${v.note}` : ""}
|
|
92
|
+
</span>
|
|
93
|
+
<span className="text-muted-foreground text-[11.5px]">
|
|
94
|
+
{versionDetail(v, false)}
|
|
95
|
+
</span>
|
|
96
|
+
</DropdownMenuItem>
|
|
97
|
+
))}
|
|
98
|
+
</DropdownMenuContent>
|
|
99
|
+
</DropdownMenu>
|
|
100
|
+
)}
|
|
101
|
+
{actions}
|
|
74
102
|
</div>
|
|
75
103
|
<ArtifactFrame
|
|
76
|
-
artifactId={
|
|
77
|
-
title={
|
|
104
|
+
artifactId={shownId}
|
|
105
|
+
title={title}
|
|
78
106
|
maxHeight={item.height}
|
|
79
107
|
/>
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
className="flex h-
|
|
108
|
+
{many && shown && latest && shown.id !== latest.id && (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
className="text-muted-foreground flex min-h-11 w-full items-center gap-1.5 border-t px-3.5 text-left text-[12.5px] md:min-h-9"
|
|
112
|
+
onClick={() => setShownId(latest.id)}
|
|
113
|
+
>
|
|
114
|
+
<History className="h-3.5 w-3.5" />
|
|
115
|
+
{latest.version - shown.version === 1
|
|
116
|
+
? "Updated since."
|
|
117
|
+
: `Updated ${latest.version - shown.version} times since.`}
|
|
118
|
+
<span className="text-primary">See v{latest.version}</span>
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
121
|
+
{many && shownId !== item.artifactId && shown?.id === latest?.id && (
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
className="text-muted-foreground flex min-h-11 w-full items-center gap-1.5 border-t px-3.5 text-left text-[12.5px] md:min-h-9"
|
|
125
|
+
onClick={() => setShownId(item.artifactId)}
|
|
84
126
|
>
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
{
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
title={item.title}
|
|
104
|
-
fill
|
|
105
|
-
/>
|
|
127
|
+
<History className="h-3.5 w-3.5" />
|
|
128
|
+
<span className="text-primary">
|
|
129
|
+
Back to v{item.version ?? 1}, from this turn
|
|
130
|
+
</span>
|
|
131
|
+
</button>
|
|
132
|
+
)}
|
|
133
|
+
<ArtifactFullscreen
|
|
134
|
+
open={full}
|
|
135
|
+
onOpenChange={setFull}
|
|
136
|
+
artifactId={shownId}
|
|
137
|
+
title={title}
|
|
138
|
+
actions={
|
|
139
|
+
<ArtifactActions
|
|
140
|
+
artifactId={shownId}
|
|
141
|
+
title={title}
|
|
142
|
+
version={many ? shown?.version : undefined}
|
|
143
|
+
actions={(data?.actions ?? FALLBACK_ACTIONS).filter(
|
|
144
|
+
(a) => a.id !== "fullscreen"
|
|
106
145
|
)}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
146
|
+
variant="icons"
|
|
147
|
+
/>
|
|
148
|
+
}
|
|
149
|
+
/>
|
|
110
150
|
</div>
|
|
111
151
|
);
|
|
112
152
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
4
|
import { toast } from "sonner";
|
|
5
5
|
import {
|
|
6
6
|
ChevronDown,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "@/components/ui/dropdown-menu";
|
|
20
20
|
import {
|
|
21
21
|
useSharePdf,
|
|
22
|
+
useSharePreview,
|
|
22
23
|
type PreviewRequest,
|
|
23
24
|
type SharePreview,
|
|
24
25
|
} from "@/data/shares";
|
|
@@ -39,60 +40,84 @@ function save(blob: Blob, name: string) {
|
|
|
39
40
|
URL.revokeObjectURL(url);
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// Saves the share in a format: PDF printed by the server, HTML and Markdown
|
|
44
|
+
// from the preview, fetched now when there isn't one in hand.
|
|
45
|
+
export function useShareDownload(
|
|
46
|
+
request: PreviewRequest,
|
|
47
|
+
title: string,
|
|
48
|
+
preview?: SharePreview | null
|
|
49
|
+
) {
|
|
50
|
+
const pdf = useSharePdf();
|
|
51
|
+
const fetchPreview = useSharePreview();
|
|
52
|
+
const choose = async (format: ShareFormat) => {
|
|
53
|
+
try {
|
|
54
|
+
if (format === "pdf") {
|
|
55
|
+
save(await pdf.mutateAsync(request), fileName(title, "pdf"));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const p = preview ?? (await fetchPreview.mutateAsync(request));
|
|
59
|
+
const md = format === "md";
|
|
60
|
+
save(
|
|
61
|
+
new Blob([md ? p.markdown : p.html], {
|
|
62
|
+
type: md ? "text/markdown" : "text/html",
|
|
63
|
+
}),
|
|
64
|
+
fileName(title, format)
|
|
65
|
+
);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
toast.error(
|
|
68
|
+
(err as Error).message ||
|
|
69
|
+
(format === "pdf" ? "Couldn't make the PDF" : "Couldn't download it")
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
return { choose, pending: pdf.isPending || fetchPreview.isPending };
|
|
74
|
+
}
|
|
75
|
+
|
|
42
76
|
// One Download button for every format the server says it can make. PDF is
|
|
43
77
|
// printed server-side, so it shows a spinner; HTML and Markdown are in hand.
|
|
44
78
|
export function DownloadMenu({
|
|
45
79
|
preview,
|
|
80
|
+
formats = preview?.formats ?? [],
|
|
46
81
|
request,
|
|
47
82
|
title,
|
|
48
83
|
disabled,
|
|
84
|
+
trigger,
|
|
85
|
+
align = "end",
|
|
49
86
|
}: {
|
|
50
|
-
preview
|
|
87
|
+
preview?: SharePreview | null;
|
|
88
|
+
formats?: ShareFormat[];
|
|
51
89
|
request: PreviewRequest;
|
|
52
90
|
title: string;
|
|
53
91
|
disabled: boolean;
|
|
92
|
+
// The button that opens the menu, given whether a download is under way.
|
|
93
|
+
trigger?: (pending: boolean) => ReactNode;
|
|
94
|
+
align?: "start" | "end";
|
|
54
95
|
}) {
|
|
55
|
-
const pdf = useSharePdf();
|
|
56
96
|
const [open, setOpen] = useState(false);
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
const choose = async (format: ShareFormat) => {
|
|
60
|
-
if (!preview) return;
|
|
61
|
-
if (format === "pdf") {
|
|
62
|
-
try {
|
|
63
|
-
save(await pdf.mutateAsync(request), fileName(title, "pdf"));
|
|
64
|
-
} catch (err) {
|
|
65
|
-
toast.error((err as Error).message || "Couldn't make the PDF");
|
|
66
|
-
}
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const md = format === "md";
|
|
70
|
-
save(
|
|
71
|
-
new Blob([md ? preview.markdown : preview.html], {
|
|
72
|
-
type: md ? "text/markdown" : "text/html",
|
|
73
|
-
}),
|
|
74
|
-
fileName(title, format)
|
|
75
|
-
);
|
|
76
|
-
};
|
|
97
|
+
const { choose, pending } = useShareDownload(request, title, preview);
|
|
77
98
|
|
|
78
99
|
return (
|
|
79
100
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
80
|
-
<DropdownMenuTrigger asChild>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
<DropdownMenuTrigger asChild disabled={disabled || !formats.length}>
|
|
102
|
+
{trigger ? (
|
|
103
|
+
trigger(pending)
|
|
104
|
+
) : (
|
|
105
|
+
<Button
|
|
106
|
+
variant="outline"
|
|
107
|
+
className="min-h-11 flex-1 sm:flex-none"
|
|
108
|
+
disabled={disabled || !formats.length || pending}
|
|
109
|
+
>
|
|
110
|
+
{pending ? (
|
|
111
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
112
|
+
) : (
|
|
113
|
+
<Download className="h-4 w-4" />
|
|
114
|
+
)}
|
|
115
|
+
{pending ? "Preparing…" : "Download"}
|
|
116
|
+
<ChevronDown className="h-4 w-4 opacity-60" />
|
|
117
|
+
</Button>
|
|
118
|
+
)}
|
|
94
119
|
</DropdownMenuTrigger>
|
|
95
|
-
<DropdownMenuContent align=
|
|
120
|
+
<DropdownMenuContent align={align} className="min-w-44">
|
|
96
121
|
{formats.map((f) => {
|
|
97
122
|
const { label, Icon } = FORMAT[f];
|
|
98
123
|
return (
|
|
@@ -67,6 +67,7 @@ function ShareBody({
|
|
|
67
67
|
sessionId?: string;
|
|
68
68
|
artifactId?: string;
|
|
69
69
|
title: string;
|
|
70
|
+
version?: number;
|
|
70
71
|
};
|
|
71
72
|
}) {
|
|
72
73
|
const [tools, setTools] = useState(false);
|
|
@@ -154,6 +155,7 @@ function ShareBody({
|
|
|
154
155
|
<div>
|
|
155
156
|
<DialogTitle>
|
|
156
157
|
Share {kind === "chat" ? "this chat" : "this page"}
|
|
158
|
+
{target.version ? ` · v${target.version}` : ""}
|
|
157
159
|
</DialogTitle>
|
|
158
160
|
<DialogDescription>
|
|
159
161
|
A read-only snapshot, with secrets, home paths and machine names
|
|
@@ -4,4 +4,7 @@ export const sessionArtifactKeys = {
|
|
|
4
4
|
[...sessionArtifactKeys.all, sessionId] as const,
|
|
5
5
|
content: (url: string) =>
|
|
6
6
|
[...sessionArtifactKeys.all, "content", url] as const,
|
|
7
|
+
// A page's versions, by any version's id; pageId keys the invalidation.
|
|
8
|
+
versions: (pageId: string) =>
|
|
9
|
+
[...sessionArtifactKeys.all, "versions", pageId] as const,
|
|
7
10
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
2
|
import type { SessionArtifact } from "@/lib/session-artifacts/types";
|
|
3
|
+
import type { PageVersions } from "@/lib/artifacts/actions";
|
|
3
4
|
import { sessionArtifactKeys } from "./keys";
|
|
4
5
|
|
|
5
6
|
export type { SessionArtifact } from "@/lib/session-artifacts/types";
|
|
@@ -51,3 +52,27 @@ export function useArtifactText(url: string | null) {
|
|
|
51
52
|
staleTime: 5_000,
|
|
52
53
|
});
|
|
53
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every version of a page, with its note and the message that asked for it.
|
|
58
|
+
* Keyed by page, so any version's card shares one fetch.
|
|
59
|
+
*/
|
|
60
|
+
export function useArtifactVersions(
|
|
61
|
+
pageId: string,
|
|
62
|
+
artifactId: string,
|
|
63
|
+
enabled = true
|
|
64
|
+
) {
|
|
65
|
+
return useQuery({
|
|
66
|
+
queryKey: sessionArtifactKeys.versions(pageId),
|
|
67
|
+
queryFn: async (): Promise<PageVersions> => {
|
|
68
|
+
const res = await fetch(
|
|
69
|
+
`/api/artifacts/${encodeURIComponent(artifactId)}/versions`
|
|
70
|
+
);
|
|
71
|
+
const data = await res.json().catch(() => ({}));
|
|
72
|
+
if (!res.ok) throw new Error(data.error || "Couldn't load versions");
|
|
73
|
+
return data;
|
|
74
|
+
},
|
|
75
|
+
enabled,
|
|
76
|
+
staleTime: 30_000,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// What you can do with an artifact, decided by the server so every client
|
|
2
|
+
// (the web app, the native app) shows the same actions without hardcoding
|
|
3
|
+
// them. Pure and import-free so the native app can sync it.
|
|
4
|
+
|
|
5
|
+
export type ArtifactActionId =
|
|
6
|
+
| "fullscreen"
|
|
7
|
+
| "share"
|
|
8
|
+
| "open"
|
|
9
|
+
| "download"
|
|
10
|
+
| "open-pr"
|
|
11
|
+
| "copy-path"
|
|
12
|
+
| "open-in-files";
|
|
13
|
+
|
|
14
|
+
export type DownloadFormat = "pdf" | "html" | "md";
|
|
15
|
+
|
|
16
|
+
export interface ArtifactAction {
|
|
17
|
+
id: ArtifactActionId;
|
|
18
|
+
label: string;
|
|
19
|
+
// download: the formats this machine can make.
|
|
20
|
+
formats?: DownloadFormat[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// One version of a page, as the version switcher lists it.
|
|
24
|
+
export interface ArtifactVersion {
|
|
25
|
+
id: string;
|
|
26
|
+
version: number;
|
|
27
|
+
title: string;
|
|
28
|
+
note?: string;
|
|
29
|
+
// ms
|
|
30
|
+
createdAt: number;
|
|
31
|
+
bytes?: number;
|
|
32
|
+
// The message that asked for it: its place among the chat's messages
|
|
33
|
+
// (1-based) and how it starts.
|
|
34
|
+
turn?: { itemId: string; index: number; prompt: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PageVersions {
|
|
38
|
+
pageId: string;
|
|
39
|
+
latestId: string;
|
|
40
|
+
versions: ArtifactVersion[];
|
|
41
|
+
actions: ArtifactAction[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pageActions(ctx: { pdf: boolean }): ArtifactAction[] {
|
|
45
|
+
return [
|
|
46
|
+
{ id: "fullscreen", label: "Full screen" },
|
|
47
|
+
{ id: "share", label: "Share" },
|
|
48
|
+
{ id: "open", label: "Open" },
|
|
49
|
+
{
|
|
50
|
+
id: "download",
|
|
51
|
+
label: "Download",
|
|
52
|
+
formats: ctx.pdf ? ["pdf", "html", "md"] : ["html", "md"],
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The actions for anything in a session's Artifacts view.
|
|
58
|
+
export function artifactActions(
|
|
59
|
+
a: {
|
|
60
|
+
kind: string;
|
|
61
|
+
state: string;
|
|
62
|
+
path?: string;
|
|
63
|
+
url?: string;
|
|
64
|
+
},
|
|
65
|
+
ctx: { pdf: boolean }
|
|
66
|
+
): ArtifactAction[] {
|
|
67
|
+
if (a.kind === "page")
|
|
68
|
+
return a.state === "live" || a.state === "kept" ? pageActions(ctx) : [];
|
|
69
|
+
const out: ArtifactAction[] = [];
|
|
70
|
+
if (a.url) out.push({ id: "open-pr", label: "Open PR" });
|
|
71
|
+
if (a.path) out.push({ id: "copy-path", label: "Copy path" });
|
|
72
|
+
if (a.path && a.state === "live" && (a.kind === "file" || a.kind === "plan"))
|
|
73
|
+
out.push({ id: "open-in-files", label: "Open in Files" });
|
|
74
|
+
return out;
|
|
75
|
+
}
|
package/lib/artifacts/store.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { agentosHome } from "../agentos-home";
|
|
7
7
|
import fs from "fs";
|
|
8
8
|
import path from "path";
|
|
9
|
-
import { randomUUID } from "crypto";
|
|
9
|
+
import { createHash, randomUUID } from "crypto";
|
|
10
10
|
import { db } from "../db";
|
|
11
11
|
|
|
12
12
|
export interface Artifact {
|
|
@@ -15,8 +15,31 @@ export interface Artifact {
|
|
|
15
15
|
title: string;
|
|
16
16
|
path: string;
|
|
17
17
|
created_at: string;
|
|
18
|
+
// The page this is a version of; every version of a page shares it.
|
|
19
|
+
page_id: string;
|
|
20
|
+
version: number;
|
|
21
|
+
// The agent's one line on what changed.
|
|
22
|
+
note: string | null;
|
|
23
|
+
bytes: number | null;
|
|
24
|
+
sha256: string | null;
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
export interface SaveOptions {
|
|
28
|
+
// Any version's id of a page in this session: add a version to it.
|
|
29
|
+
update?: string;
|
|
30
|
+
// With no update, the newest page in this session with the same title.
|
|
31
|
+
sameTitle?: boolean;
|
|
32
|
+
note?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Saved {
|
|
36
|
+
artifact: Artifact;
|
|
37
|
+
// False when the page was byte-for-byte its latest version already.
|
|
38
|
+
created: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const MAX_NOTE_CHARS = 120;
|
|
42
|
+
|
|
20
43
|
export const MAX_HTML_BYTES = 2 * 1024 * 1024;
|
|
21
44
|
export const MAX_TITLE_CHARS = 120;
|
|
22
45
|
|
|
@@ -35,20 +58,92 @@ function sessionDir(sessionId: string): string {
|
|
|
35
58
|
export function createArtifact(
|
|
36
59
|
sessionId: string,
|
|
37
60
|
title: string,
|
|
38
|
-
html: string
|
|
61
|
+
html: string,
|
|
62
|
+
opts: SaveOptions = {}
|
|
39
63
|
): Artifact {
|
|
40
|
-
|
|
64
|
+
return saveArtifact(sessionId, title, html, opts).artifact;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const oneLine = (s: string, max: number) =>
|
|
68
|
+
s.replace(/\s+/g, " ").trim().slice(0, max);
|
|
69
|
+
|
|
70
|
+
// Saves a page, or a new version of one. Earlier versions are never touched.
|
|
71
|
+
export function saveArtifact(
|
|
72
|
+
sessionId: string,
|
|
73
|
+
title: string,
|
|
74
|
+
html: string,
|
|
75
|
+
opts: SaveOptions = {}
|
|
76
|
+
): Saved {
|
|
77
|
+
const bytes = Buffer.byteLength(html, "utf8");
|
|
78
|
+
if (bytes > MAX_HTML_BYTES)
|
|
41
79
|
throw new Error("The page is over 2 MB; make it smaller.");
|
|
80
|
+
const clean = oneLine(title, MAX_TITLE_CHARS) || "Untitled";
|
|
81
|
+
const note = opts.note ? oneLine(opts.note, MAX_NOTE_CHARS) || null : null;
|
|
82
|
+
const sha256 = createHash("sha256").update(html).digest("hex");
|
|
42
83
|
const id = randomUUID();
|
|
43
84
|
const dir = sessionDir(sessionId);
|
|
44
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
45
85
|
const file = path.join(dir, `${id}.html`);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
86
|
+
return db.transaction((): Saved => {
|
|
87
|
+
const pageId = resolvePage(sessionId, clean, opts);
|
|
88
|
+
const latest = pageId ? latestVersion(pageId) : undefined;
|
|
89
|
+
if (latest && latest.sha256 === sha256)
|
|
90
|
+
return { artifact: latest, created: false };
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
92
|
+
fs.writeFileSync(file, html, { mode: 0o600 });
|
|
93
|
+
db.prepare(
|
|
94
|
+
`INSERT INTO artifacts (id, session_id, title, path, page_id, version, note, bytes, sha256)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
96
|
+
).run(
|
|
97
|
+
id,
|
|
98
|
+
sessionId,
|
|
99
|
+
clean,
|
|
100
|
+
file,
|
|
101
|
+
pageId ?? id,
|
|
102
|
+
latest ? latest.version + 1 : 1,
|
|
103
|
+
note,
|
|
104
|
+
bytes,
|
|
105
|
+
sha256
|
|
106
|
+
);
|
|
107
|
+
return { artifact: getArtifact(id)!, created: true };
|
|
108
|
+
})();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolvePage(
|
|
112
|
+
sessionId: string,
|
|
113
|
+
title: string,
|
|
114
|
+
opts: SaveOptions
|
|
115
|
+
): string | undefined {
|
|
116
|
+
if (opts.update) {
|
|
117
|
+
const base = getArtifact(opts.update);
|
|
118
|
+
if (!base || base.session_id !== sessionId)
|
|
119
|
+
throw new Error(
|
|
120
|
+
`No page ${opts.update} in this chat; leave out update to show a new page.`
|
|
121
|
+
);
|
|
122
|
+
return base.page_id;
|
|
123
|
+
}
|
|
124
|
+
if (!opts.sameTitle) return undefined;
|
|
125
|
+
const row = db
|
|
126
|
+
.prepare(
|
|
127
|
+
`SELECT page_id FROM artifacts WHERE session_id = ? AND lower(title) = lower(?)
|
|
128
|
+
ORDER BY created_at DESC, rowid DESC LIMIT 1`
|
|
129
|
+
)
|
|
130
|
+
.get(sessionId, title) as { page_id: string } | undefined;
|
|
131
|
+
return row?.page_id;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Every version of a page, oldest first.
|
|
135
|
+
export function listVersions(pageId: string): Artifact[] {
|
|
136
|
+
return db
|
|
137
|
+
.prepare(`SELECT * FROM artifacts WHERE page_id = ? ORDER BY version`)
|
|
138
|
+
.all(pageId) as Artifact[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function latestVersion(pageId: string): Artifact | undefined {
|
|
142
|
+
return db
|
|
143
|
+
.prepare(
|
|
144
|
+
`SELECT * FROM artifacts WHERE page_id = ? ORDER BY version DESC LIMIT 1`
|
|
145
|
+
)
|
|
146
|
+
.get(pageId) as Artifact | undefined;
|
|
52
147
|
}
|
|
53
148
|
|
|
54
149
|
export function getArtifact(id: string): Artifact | undefined {
|