@saadnvd1/agent-os 0.3.770 → 0.3.775
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/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
- package/app/api/shares/preview/route.ts +6 -1
- package/app/api/shares/route.test.ts +51 -0
- package/app/page.tsx +1 -0
- package/components/Artifacts/ArtifactActions.tsx +27 -3
- package/components/Artifacts/ArtifactPreview.tsx +27 -12
- package/components/Artifacts/Compare/DiffView.tsx +116 -0
- package/components/Artifacts/Compare/SideBySide.tsx +60 -0
- package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
- package/components/Artifacts/Compare/index.tsx +191 -0
- package/components/Chat/Messages.tsx +6 -0
- package/components/Chat/ShareFromHere.tsx +39 -0
- package/components/CommandPalette/CommandPalette.tsx +5 -4
- package/components/CommandPalette/CommandsBody.tsx +97 -131
- package/components/CommandPalette/PaletteRows.tsx +79 -0
- package/components/CommandPalette/PaletteTabs.tsx +51 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
- package/components/CommandPalette/useAppCommands.ts +14 -0
- package/components/CommandPalette/useConversationRows.ts +55 -0
- package/components/SessionList/useSessionSwitching.ts +2 -2
- package/components/Share/MessageList.tsx +87 -0
- package/components/Share/RangePicker.tsx +59 -0
- package/components/Share/ShareDialog.tsx +113 -52
- package/data/session-artifacts/keys.ts +2 -0
- package/data/session-artifacts/queries.ts +25 -0
- package/data/shares/queries.ts +11 -0
- package/hooks/usePaletteCommands.ts +4 -1
- package/lib/artifacts/actions.ts +11 -1
- package/lib/artifacts/compare/compareVersions.ts +66 -0
- package/lib/artifacts/compare/diff.ts +178 -0
- package/lib/artifacts/compare/extractText.ts +64 -0
- package/lib/artifacts/compare/index.ts +11 -0
- package/lib/artifacts/compare/types.ts +44 -0
- package/lib/artifacts/match.ts +64 -0
- package/lib/artifacts/store.ts +39 -10
- package/lib/artifacts/tools.ts +9 -4
- package/lib/artifacts/versions.ts +2 -1
- package/lib/palette/registry.ts +71 -17
- package/lib/palette/sections.ts +143 -0
- package/lib/palette/tabs.ts +21 -0
- package/lib/palette/usage.ts +81 -0
- package/lib/security/demo.ts +3 -0
- package/lib/share/markdown.ts +3 -0
- package/lib/share/range.ts +189 -0
- package/lib/share/render.tsx +3 -0
- package/lib/share/service.ts +64 -19
- package/lib/share/snapshot.ts +39 -2
- package/lib/share/styles.ts +2 -0
- package/lib/share/view.ts +11 -0
- package/package.json +1 -1
- package/stores/palette.ts +13 -11
- package/stores/shareUi.ts +6 -1
package/data/shares/queries.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import type {
|
|
3
3
|
CreatedShare,
|
|
4
|
+
MessageList,
|
|
4
5
|
PreviewRequest,
|
|
5
6
|
SharePreview,
|
|
6
7
|
SharesResponse,
|
|
@@ -40,6 +41,16 @@ export function useSharePreview() {
|
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** Every message of a chat, for picking the part to share. */
|
|
45
|
+
export function useShareMessages() {
|
|
46
|
+
return useMutation({
|
|
47
|
+
mutationFn: async (req: PreviewRequest) =>
|
|
48
|
+
json<MessageList>(
|
|
49
|
+
await post("/api/shares/preview", { ...req, listOnly: true })
|
|
50
|
+
),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
/** The share as a PDF, printed by the server. */
|
|
44
55
|
export function useSharePdf() {
|
|
45
56
|
return useMutation({
|
|
@@ -17,7 +17,10 @@ export function usePaletteCommands(
|
|
|
17
17
|
});
|
|
18
18
|
const shape = commands
|
|
19
19
|
? commands
|
|
20
|
-
.map(
|
|
20
|
+
.map(
|
|
21
|
+
(c) =>
|
|
22
|
+
`${c.id}\u0000${c.title}\u0000${c.hint ?? ""}\u0000${c.status ?? ""}`
|
|
23
|
+
)
|
|
21
24
|
.join("\u0001")
|
|
22
25
|
: null;
|
|
23
26
|
useEffect(() => {
|
package/lib/artifacts/actions.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type ArtifactActionId =
|
|
|
7
7
|
| "share"
|
|
8
8
|
| "open"
|
|
9
9
|
| "download"
|
|
10
|
+
| "compare"
|
|
10
11
|
| "open-pr"
|
|
11
12
|
| "copy-path"
|
|
12
13
|
| "open-in-files";
|
|
@@ -36,16 +37,25 @@ export interface ArtifactVersion {
|
|
|
36
37
|
|
|
37
38
|
export interface PageVersions {
|
|
38
39
|
pageId: string;
|
|
40
|
+
// The page's name: its latest version's title.
|
|
41
|
+
title: string;
|
|
39
42
|
latestId: string;
|
|
40
43
|
versions: ArtifactVersion[];
|
|
41
44
|
actions: ArtifactAction[];
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
// versions: how many versions the page has; Compare needs two.
|
|
48
|
+
export function pageActions(ctx: {
|
|
49
|
+
pdf: boolean;
|
|
50
|
+
versions?: number;
|
|
51
|
+
}): ArtifactAction[] {
|
|
45
52
|
return [
|
|
46
53
|
{ id: "fullscreen", label: "Full screen" },
|
|
47
54
|
{ id: "share", label: "Share" },
|
|
48
55
|
{ id: "open", label: "Open" },
|
|
56
|
+
...((ctx.versions ?? 1) > 1
|
|
57
|
+
? [{ id: "compare" as const, label: "Compare" }]
|
|
58
|
+
: []),
|
|
49
59
|
{
|
|
50
60
|
id: "download",
|
|
51
61
|
label: "Download",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getArtifact, readArtifactHtml } from "../store";
|
|
2
|
+
import { toVersion } from "../versions";
|
|
3
|
+
import { diffLines } from "./diff";
|
|
4
|
+
import { extractText } from "./extractText";
|
|
5
|
+
import type { CompareMode, CompareResult } from "./types";
|
|
6
|
+
|
|
7
|
+
// Per version; bigger pages are only shown side by side.
|
|
8
|
+
export const MAX_COMPARE_BYTES = 512 * 1024;
|
|
9
|
+
|
|
10
|
+
const NOT_FOUND = {
|
|
11
|
+
ok: false,
|
|
12
|
+
status: 404,
|
|
13
|
+
error: "Version not found",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
// Two versions of one page in one session, as a diff. Both must belong to
|
|
17
|
+
// the session asked about and to the same page: an id from anywhere else
|
|
18
|
+
// reads as not found, so ids can't be probed across sessions.
|
|
19
|
+
export async function compareVersions(
|
|
20
|
+
sessionId: string,
|
|
21
|
+
fromId: string,
|
|
22
|
+
toId: string,
|
|
23
|
+
mode: CompareMode
|
|
24
|
+
): Promise<CompareResult> {
|
|
25
|
+
const from = getArtifact(fromId);
|
|
26
|
+
const to = getArtifact(toId);
|
|
27
|
+
if (!from || !to) return NOT_FOUND;
|
|
28
|
+
if (from.session_id !== sessionId || to.session_id !== sessionId)
|
|
29
|
+
return NOT_FOUND;
|
|
30
|
+
if (from.page_id !== to.page_id)
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
status: 400,
|
|
34
|
+
error: "Those versions belong to different pages",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const head = {
|
|
38
|
+
ok: true,
|
|
39
|
+
mode,
|
|
40
|
+
from: toVersion(from),
|
|
41
|
+
to: toVersion(to),
|
|
42
|
+
} as const;
|
|
43
|
+
const tooBig = (a: { bytes: number | null }) =>
|
|
44
|
+
(a.bytes ?? 0) > MAX_COMPARE_BYTES;
|
|
45
|
+
const tooLarge = (reason: string): CompareResult => ({
|
|
46
|
+
...head,
|
|
47
|
+
tooLarge: true,
|
|
48
|
+
reason,
|
|
49
|
+
});
|
|
50
|
+
if (tooBig(from) || tooBig(to)) return tooLarge("too large to diff");
|
|
51
|
+
|
|
52
|
+
const [a, b] = await Promise.all([
|
|
53
|
+
readArtifactHtml(from),
|
|
54
|
+
readArtifactHtml(to),
|
|
55
|
+
]);
|
|
56
|
+
if (a === null || b === null) return NOT_FOUND;
|
|
57
|
+
if (a.length > MAX_COMPARE_BYTES || b.length > MAX_COMPARE_BYTES)
|
|
58
|
+
return tooLarge("too large to diff");
|
|
59
|
+
|
|
60
|
+
const result =
|
|
61
|
+
mode === "source"
|
|
62
|
+
? diffLines(a, b)
|
|
63
|
+
: diffLines(extractText(a), extractText(b));
|
|
64
|
+
if (!result.ok) return tooLarge("too many changes to diff");
|
|
65
|
+
return { ...head, summary: result.summary, lines: result.lines };
|
|
66
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// A line diff (Myers' O(ND)) with a word diff inside the changed lines that
|
|
2
|
+
// pair up. Bounded: past the limits it gives up instead of burning the CPU.
|
|
3
|
+
|
|
4
|
+
import type { DiffLine, DiffSummary, WordPart } from "./types";
|
|
5
|
+
|
|
6
|
+
export const MAX_LINES = 20_000;
|
|
7
|
+
// Edit distance past which two texts are different enough not to diff.
|
|
8
|
+
export const MAX_EDITS = 4_000;
|
|
9
|
+
const MAX_WORD_TOKENS = 600;
|
|
10
|
+
|
|
11
|
+
type Op = { t: "eq" | "add" | "del"; i: number; j: number };
|
|
12
|
+
|
|
13
|
+
// Edit script from a to b, or null when it needs more than maxEdits.
|
|
14
|
+
function myers(a: string[], b: string[], maxEdits: number): Op[] | null {
|
|
15
|
+
const n = a.length;
|
|
16
|
+
const m = b.length;
|
|
17
|
+
const max = Math.min(n + m, maxEdits);
|
|
18
|
+
const offset = max + 1;
|
|
19
|
+
const v = new Int32Array(2 * max + 3);
|
|
20
|
+
const trace: Int32Array[] = [];
|
|
21
|
+
let found = -1;
|
|
22
|
+
for (let d = 0; d <= max && found < 0; d++) {
|
|
23
|
+
trace.push(v.slice());
|
|
24
|
+
for (let k = -d; k <= d; k += 2) {
|
|
25
|
+
let x =
|
|
26
|
+
k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])
|
|
27
|
+
? v[offset + k + 1]
|
|
28
|
+
: v[offset + k - 1] + 1;
|
|
29
|
+
let y = x - k;
|
|
30
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
31
|
+
x++;
|
|
32
|
+
y++;
|
|
33
|
+
}
|
|
34
|
+
v[offset + k] = x;
|
|
35
|
+
if (x >= n && y >= m) {
|
|
36
|
+
found = d;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (found < 0) return null;
|
|
42
|
+
const ops: Op[] = [];
|
|
43
|
+
let x = n;
|
|
44
|
+
let y = m;
|
|
45
|
+
for (let d = found; d > 0; d--) {
|
|
46
|
+
const prev = trace[d];
|
|
47
|
+
const k = x - y;
|
|
48
|
+
const down =
|
|
49
|
+
k === -d || (k !== d && prev[offset + k - 1] < prev[offset + k + 1]);
|
|
50
|
+
const pk = down ? k + 1 : k - 1;
|
|
51
|
+
const px = prev[offset + pk];
|
|
52
|
+
const py = px - pk;
|
|
53
|
+
while (x > px && y > py) {
|
|
54
|
+
x--;
|
|
55
|
+
y--;
|
|
56
|
+
ops.push({ t: "eq", i: x, j: y });
|
|
57
|
+
}
|
|
58
|
+
if (down) {
|
|
59
|
+
y--;
|
|
60
|
+
ops.push({ t: "add", i: x, j: y });
|
|
61
|
+
} else {
|
|
62
|
+
x--;
|
|
63
|
+
ops.push({ t: "del", i: x, j: y });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
while (x > 0 && y > 0) {
|
|
67
|
+
x--;
|
|
68
|
+
y--;
|
|
69
|
+
ops.push({ t: "eq", i: x, j: y });
|
|
70
|
+
}
|
|
71
|
+
return ops.reverse();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tokens = (s: string) =>
|
|
75
|
+
s.match(/\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu) ?? [];
|
|
76
|
+
|
|
77
|
+
// The words of two paired lines: what each side kept and what it changed.
|
|
78
|
+
function wordParts(
|
|
79
|
+
before: string,
|
|
80
|
+
after: string
|
|
81
|
+
): { del: WordPart[]; add: WordPart[] } | null {
|
|
82
|
+
const a = tokens(before);
|
|
83
|
+
const b = tokens(after);
|
|
84
|
+
if (a.length > MAX_WORD_TOKENS || b.length > MAX_WORD_TOKENS) return null;
|
|
85
|
+
const ops = myers(a, b, MAX_WORD_TOKENS);
|
|
86
|
+
if (!ops) return null;
|
|
87
|
+
const del: WordPart[] = [];
|
|
88
|
+
const add: WordPart[] = [];
|
|
89
|
+
const push = (list: WordPart[], t: WordPart["t"], s: string) => {
|
|
90
|
+
const last = list[list.length - 1];
|
|
91
|
+
if (last && last.t === t) last.s += s;
|
|
92
|
+
else list.push({ t, s });
|
|
93
|
+
};
|
|
94
|
+
for (const op of ops) {
|
|
95
|
+
if (op.t === "eq") {
|
|
96
|
+
push(del, "eq", a[op.i]);
|
|
97
|
+
push(add, "eq", b[op.j]);
|
|
98
|
+
} else if (op.t === "del") push(del, "del", a[op.i]);
|
|
99
|
+
else push(add, "add", b[op.j]);
|
|
100
|
+
}
|
|
101
|
+
return { del, add };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type DiffResult =
|
|
105
|
+
{ ok: true; lines: DiffLine[]; summary: DiffSummary } | { ok: false };
|
|
106
|
+
|
|
107
|
+
export function diffLines(before: string, after: string): DiffResult {
|
|
108
|
+
const a = before === "" ? [] : before.split("\n");
|
|
109
|
+
const b = after === "" ? [] : after.split("\n");
|
|
110
|
+
if (a.length > MAX_LINES || b.length > MAX_LINES) return { ok: false };
|
|
111
|
+
const ops = myers(a, b, MAX_EDITS);
|
|
112
|
+
if (!ops) return { ok: false };
|
|
113
|
+
|
|
114
|
+
const lines: DiffLine[] = [];
|
|
115
|
+
let added = 0;
|
|
116
|
+
let removed = 0;
|
|
117
|
+
for (let p = 0; p < ops.length;) {
|
|
118
|
+
if (ops[p].t === "eq") {
|
|
119
|
+
lines.push({ t: "eq", s: a[ops[p].i] });
|
|
120
|
+
p++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// A run of changes: removals first, then additions, paired in order.
|
|
124
|
+
const dels: string[] = [];
|
|
125
|
+
const adds: string[] = [];
|
|
126
|
+
while (p < ops.length && ops[p].t !== "eq") {
|
|
127
|
+
if (ops[p].t === "del") dels.push(a[ops[p].i]);
|
|
128
|
+
else adds.push(b[ops[p].j]);
|
|
129
|
+
p++;
|
|
130
|
+
}
|
|
131
|
+
removed += dels.length;
|
|
132
|
+
added += adds.length;
|
|
133
|
+
const delLines: DiffLine[] = dels.map((s) => ({ t: "del", s }));
|
|
134
|
+
const addLines: DiffLine[] = adds.map((s) => ({ t: "add", s }));
|
|
135
|
+
for (let q = 0; q < Math.min(dels.length, adds.length); q++) {
|
|
136
|
+
const w = wordParts(dels[q], adds[q]);
|
|
137
|
+
if (w) {
|
|
138
|
+
delLines[q].words = w.del;
|
|
139
|
+
addLines[q].words = w.add;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
lines.push(...delLines, ...addLines);
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, lines, summary: { added, removed } };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function summaryLabel(s: DiffSummary): string {
|
|
148
|
+
if (!s.added && !s.removed) return "No changes";
|
|
149
|
+
return `+${s.added} −${s.removed} lines`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type Shown = DiffLine | { gap: number };
|
|
153
|
+
|
|
154
|
+
// Long unchanged stretches folded to a gap, keeping `context` lines around
|
|
155
|
+
// every change.
|
|
156
|
+
export function foldUnchanged(lines: DiffLine[], context = 3): Shown[] {
|
|
157
|
+
const keep = new Array<boolean>(lines.length).fill(false);
|
|
158
|
+
lines.forEach((l, i) => {
|
|
159
|
+
if (l.t === "eq") return;
|
|
160
|
+
for (
|
|
161
|
+
let k = Math.max(0, i - context);
|
|
162
|
+
k <= Math.min(lines.length - 1, i + context);
|
|
163
|
+
k++
|
|
164
|
+
)
|
|
165
|
+
keep[k] = true;
|
|
166
|
+
});
|
|
167
|
+
const out: Shown[] = [];
|
|
168
|
+
let gap = 0;
|
|
169
|
+
lines.forEach((l, i) => {
|
|
170
|
+
if (keep[i]) {
|
|
171
|
+
if (gap) out.push({ gap });
|
|
172
|
+
gap = 0;
|
|
173
|
+
out.push(l);
|
|
174
|
+
} else gap++;
|
|
175
|
+
});
|
|
176
|
+
if (gap) out.push({ gap });
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The text a reader sees on a page, one block per line: scripts, styles and
|
|
2
|
+
// the head gone, headings, paragraphs, list items and table cells each on
|
|
3
|
+
// their own line.
|
|
4
|
+
|
|
5
|
+
const DROPPED =
|
|
6
|
+
/<(script|style|noscript|template|head|svg|iframe)\b[\s\S]*?<\/\1\s*>/gi;
|
|
7
|
+
const COMMENTS = /<!--[\s\S]*?-->/g;
|
|
8
|
+
const BREAKING =
|
|
9
|
+
/<\/?(?:p|div|section|article|aside|header|footer|main|nav|ul|ol|li|dl|dt|dd|h[1-6]|tr|td|th|table|thead|tbody|tfoot|caption|blockquote|pre|figure|figcaption|form|fieldset|details|summary|hr|br|address|label|button|option)\b[^>]*>/gi;
|
|
10
|
+
const TAGS = /<[^>]*>/g;
|
|
11
|
+
|
|
12
|
+
const NAMED: Record<string, string> = {
|
|
13
|
+
amp: "&",
|
|
14
|
+
lt: "<",
|
|
15
|
+
gt: ">",
|
|
16
|
+
quot: '"',
|
|
17
|
+
apos: "'",
|
|
18
|
+
nbsp: " ",
|
|
19
|
+
ndash: "–",
|
|
20
|
+
mdash: "—",
|
|
21
|
+
hellip: "…",
|
|
22
|
+
lsquo: "‘",
|
|
23
|
+
rsquo: "’",
|
|
24
|
+
ldquo: "“",
|
|
25
|
+
rdquo: "”",
|
|
26
|
+
middot: "·",
|
|
27
|
+
bull: "•",
|
|
28
|
+
times: "×",
|
|
29
|
+
rarr: "→",
|
|
30
|
+
larr: "←",
|
|
31
|
+
copy: "©",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function codePoint(n: number, whole: string): string {
|
|
35
|
+
try {
|
|
36
|
+
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : whole;
|
|
37
|
+
} catch {
|
|
38
|
+
return whole;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decodeEntities(s: string): string {
|
|
43
|
+
return s.replace(
|
|
44
|
+
/&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]{2,8}));/g,
|
|
45
|
+
(whole, dec, hex, name) => {
|
|
46
|
+
if (dec) return codePoint(parseInt(dec, 10), whole);
|
|
47
|
+
if (hex) return codePoint(parseInt(hex, 16), whole);
|
|
48
|
+
return NAMED[name.toLowerCase()] ?? whole;
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractText(html: string): string {
|
|
54
|
+
const flat = html
|
|
55
|
+
.replace(COMMENTS, "")
|
|
56
|
+
.replace(DROPPED, "")
|
|
57
|
+
.replace(BREAKING, "\n")
|
|
58
|
+
.replace(TAGS, "");
|
|
59
|
+
return decodeEntities(flat)
|
|
60
|
+
.split("\n")
|
|
61
|
+
.map((l) => l.replace(/\s+/g, " ").trim())
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join("\n");
|
|
64
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { compareVersions, MAX_COMPARE_BYTES } from "./compareVersions";
|
|
2
|
+
export { diffLines, foldUnchanged, summaryLabel, type Shown } from "./diff";
|
|
3
|
+
export { extractText } from "./extractText";
|
|
4
|
+
export type {
|
|
5
|
+
CompareMode,
|
|
6
|
+
CompareResult,
|
|
7
|
+
Comparison,
|
|
8
|
+
DiffLine,
|
|
9
|
+
DiffSummary,
|
|
10
|
+
WordPart,
|
|
11
|
+
} from "./types";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ArtifactVersion } from "../actions";
|
|
2
|
+
|
|
3
|
+
export type CompareMode = "changes" | "source";
|
|
4
|
+
|
|
5
|
+
// A piece of one line: kept, or (on a removed line) taken out, or (on an
|
|
6
|
+
// added line) put in.
|
|
7
|
+
export interface WordPart {
|
|
8
|
+
t: "eq" | "add" | "del";
|
|
9
|
+
s: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DiffLine {
|
|
13
|
+
t: "eq" | "add" | "del";
|
|
14
|
+
s: string;
|
|
15
|
+
// Only on a removed or added line that pairs with one on the other side.
|
|
16
|
+
words?: WordPart[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DiffSummary {
|
|
20
|
+
added: number;
|
|
21
|
+
removed: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Comparison =
|
|
25
|
+
| {
|
|
26
|
+
ok: true;
|
|
27
|
+
mode: CompareMode;
|
|
28
|
+
from: ArtifactVersion;
|
|
29
|
+
to: ArtifactVersion;
|
|
30
|
+
summary: DiffSummary;
|
|
31
|
+
lines: DiffLine[];
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
ok: true;
|
|
35
|
+
mode: CompareMode;
|
|
36
|
+
from: ArtifactVersion;
|
|
37
|
+
to: ArtifactVersion;
|
|
38
|
+
// Too big to diff: the client shows the pages side by side only.
|
|
39
|
+
tooLarge: true;
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type CompareResult =
|
|
44
|
+
Comparison | { ok: false; status: 400 | 404; error: string };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Finds which earlier page a new render is a revision of, by its visible text.
|
|
2
|
+
// Used when an agent retitles a page without passing `update`.
|
|
3
|
+
|
|
4
|
+
// Word-5-gram overlap (Jaccard) of the visible text. A revision keeps most of
|
|
5
|
+
// its sentences, so it stays well above 0.6; two pages that only share a
|
|
6
|
+
// template (nav, footer, same CSS) have different text and land far below.
|
|
7
|
+
export const MATCH_THRESHOLD = 0.6;
|
|
8
|
+
// A clear winner: the runner-up must trail by this much, else it is a tie.
|
|
9
|
+
export const MATCH_MARGIN = 0.1;
|
|
10
|
+
// Too little text to tell documents apart.
|
|
11
|
+
export const MIN_TOKENS = 12;
|
|
12
|
+
export const SHINGLE = 5;
|
|
13
|
+
export const MAX_CANDIDATES = 20;
|
|
14
|
+
|
|
15
|
+
export function visibleText(html: string): string {
|
|
16
|
+
return html
|
|
17
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
18
|
+
.replace(/<(script|style|noscript|template|svg)\b[\s\S]*?<\/\1\s*>/gi, " ")
|
|
19
|
+
.replace(/<[^>]*>/g, " ")
|
|
20
|
+
.replace(/ /gi, " ")
|
|
21
|
+
.replace(/&/gi, "&")
|
|
22
|
+
.replace(/\s+/g, " ")
|
|
23
|
+
.trim()
|
|
24
|
+
.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function tokens(html: string): string[] {
|
|
28
|
+
return visibleText(html).match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shingles(toks: string[]): Set<string> {
|
|
32
|
+
const out = new Set<string>();
|
|
33
|
+
const n = Math.min(SHINGLE, toks.length);
|
|
34
|
+
for (let i = 0; i + n <= toks.length; i++)
|
|
35
|
+
out.add(toks.slice(i, i + n).join(" "));
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function similarity(a: string, b: string): number {
|
|
40
|
+
const ta = tokens(a);
|
|
41
|
+
const tb = tokens(b);
|
|
42
|
+
if (ta.length < MIN_TOKENS || tb.length < MIN_TOKENS) return 0;
|
|
43
|
+
const sa = shingles(ta);
|
|
44
|
+
const sb = shingles(tb);
|
|
45
|
+
let shared = 0;
|
|
46
|
+
for (const s of sa) if (sb.has(s)) shared++;
|
|
47
|
+
const union = sa.size + sb.size - shared;
|
|
48
|
+
return union ? shared / union : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The id of the one candidate that clearly is the same document, if any.
|
|
52
|
+
export function findRevisionOf<T extends { key: string; html: string }>(
|
|
53
|
+
html: string,
|
|
54
|
+
candidates: T[]
|
|
55
|
+
): string | undefined {
|
|
56
|
+
const scored = candidates
|
|
57
|
+
.slice(0, MAX_CANDIDATES)
|
|
58
|
+
.map((c) => ({ key: c.key, score: similarity(html, c.html) }))
|
|
59
|
+
.sort((a, b) => b.score - a.score);
|
|
60
|
+
const [best, next] = scored;
|
|
61
|
+
if (!best || best.score < MATCH_THRESHOLD) return undefined;
|
|
62
|
+
if (next && best.score - next.score < MATCH_MARGIN) return undefined;
|
|
63
|
+
return best.key;
|
|
64
|
+
}
|
package/lib/artifacts/store.ts
CHANGED
|
@@ -8,6 +8,7 @@ import fs from "fs";
|
|
|
8
8
|
import path from "path";
|
|
9
9
|
import { createHash, randomUUID } from "crypto";
|
|
10
10
|
import { db } from "../db";
|
|
11
|
+
import { findRevisionOf, MAX_CANDIDATES } from "./match";
|
|
11
12
|
|
|
12
13
|
export interface Artifact {
|
|
13
14
|
id: string;
|
|
@@ -29,6 +30,9 @@ export interface SaveOptions {
|
|
|
29
30
|
update?: string;
|
|
30
31
|
// With no update, the newest page in this session with the same title.
|
|
31
32
|
sameTitle?: boolean;
|
|
33
|
+
// With no update and no title match, the page in this session whose latest
|
|
34
|
+
// version has clearly the same text (see match.ts).
|
|
35
|
+
matchContent?: boolean;
|
|
32
36
|
note?: string;
|
|
33
37
|
}
|
|
34
38
|
|
|
@@ -78,16 +82,18 @@ export function saveArtifact(
|
|
|
78
82
|
if (bytes > MAX_HTML_BYTES)
|
|
79
83
|
throw new Error("The page is over 2 MB; make it smaller.");
|
|
80
84
|
const clean = oneLine(title, MAX_TITLE_CHARS) || "Untitled";
|
|
81
|
-
|
|
85
|
+
let note = opts.note ? oneLine(opts.note, MAX_NOTE_CHARS) || null : null;
|
|
82
86
|
const sha256 = createHash("sha256").update(html).digest("hex");
|
|
83
87
|
const id = randomUUID();
|
|
84
88
|
const dir = sessionDir(sessionId);
|
|
85
89
|
const file = path.join(dir, `${id}.html`);
|
|
86
90
|
return db.transaction((): Saved => {
|
|
87
|
-
const pageId = resolvePage(sessionId, clean, opts);
|
|
91
|
+
const { pageId, fuzzy } = resolvePage(sessionId, clean, html, opts);
|
|
88
92
|
const latest = pageId ? latestVersion(pageId) : undefined;
|
|
89
93
|
if (latest && latest.sha256 === sha256)
|
|
90
94
|
return { artifact: latest, created: false };
|
|
95
|
+
if (fuzzy && latest && !note && latest.title !== clean)
|
|
96
|
+
note = oneLine(`Retitled from '${latest.title}'`, MAX_NOTE_CHARS);
|
|
91
97
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
92
98
|
fs.writeFileSync(file, html, { mode: 0o600 });
|
|
93
99
|
db.prepare(
|
|
@@ -111,24 +117,47 @@ export function saveArtifact(
|
|
|
111
117
|
function resolvePage(
|
|
112
118
|
sessionId: string,
|
|
113
119
|
title: string,
|
|
120
|
+
html: string,
|
|
114
121
|
opts: SaveOptions
|
|
115
|
-
): string
|
|
122
|
+
): { pageId?: string; fuzzy?: boolean } {
|
|
116
123
|
if (opts.update) {
|
|
117
124
|
const base = getArtifact(opts.update);
|
|
118
125
|
if (!base || base.session_id !== sessionId)
|
|
119
126
|
throw new Error(
|
|
120
127
|
`No page ${opts.update} in this chat; leave out update to show a new page.`
|
|
121
128
|
);
|
|
122
|
-
return base.page_id;
|
|
129
|
+
return { pageId: base.page_id };
|
|
130
|
+
}
|
|
131
|
+
if (!opts.sameTitle && !opts.matchContent) return {};
|
|
132
|
+
// A page is named by its latest version, so an older title no longer finds it.
|
|
133
|
+
const latest = latestPerPage(sessionId, MAX_CANDIDATES);
|
|
134
|
+
if (opts.sameTitle) {
|
|
135
|
+
const lower = title.toLowerCase();
|
|
136
|
+
const same = latest.find((a) => a.title.toLowerCase() === lower);
|
|
137
|
+
if (same) return { pageId: same.page_id };
|
|
123
138
|
}
|
|
124
|
-
if (!opts.
|
|
125
|
-
const
|
|
139
|
+
if (!opts.matchContent) return {};
|
|
140
|
+
const candidates = latest.flatMap((a) => {
|
|
141
|
+
try {
|
|
142
|
+
return [{ key: a.page_id, html: fs.readFileSync(a.path, "utf8") }];
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
const pageId = findRevisionOf(html, candidates);
|
|
148
|
+
return pageId ? { pageId, fuzzy: true } : {};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The newest version of each page in a session, most recently shown first.
|
|
152
|
+
function latestPerPage(sessionId: string, limit: number): Artifact[] {
|
|
153
|
+
return db
|
|
126
154
|
.prepare(
|
|
127
|
-
`SELECT
|
|
128
|
-
|
|
155
|
+
`SELECT a.* FROM artifacts a
|
|
156
|
+
WHERE a.session_id = ?
|
|
157
|
+
AND a.version = (SELECT MAX(version) FROM artifacts WHERE page_id = a.page_id)
|
|
158
|
+
ORDER BY a.created_at DESC, a.rowid DESC LIMIT ?`
|
|
129
159
|
)
|
|
130
|
-
.
|
|
131
|
-
return row?.page_id;
|
|
160
|
+
.all(sessionId, limit) as Artifact[];
|
|
132
161
|
}
|
|
133
162
|
|
|
134
163
|
// Every version of a page, oldest first.
|
package/lib/artifacts/tools.ts
CHANGED
|
@@ -100,7 +100,12 @@ export async function renderPage(
|
|
|
100
100
|
ctx.sessionId,
|
|
101
101
|
args.title,
|
|
102
102
|
readSource(args, ctx.cwd),
|
|
103
|
-
{
|
|
103
|
+
{
|
|
104
|
+
update: args.update,
|
|
105
|
+
note: args.note,
|
|
106
|
+
sameTitle: true,
|
|
107
|
+
matchContent: true,
|
|
108
|
+
}
|
|
104
109
|
);
|
|
105
110
|
if (created) {
|
|
106
111
|
ctx.onRender(artifact, args.height);
|
|
@@ -112,7 +117,7 @@ export async function renderPage(
|
|
|
112
117
|
{
|
|
113
118
|
type: "text",
|
|
114
119
|
text: created
|
|
115
|
-
? `Shown in the chat as ${name} (artifact ${artifact.id}). To
|
|
120
|
+
? `Shown in the chat as ${name} (artifact ${artifact.id}). To revise this page, pass update: "${artifact.id}" to html_render, even if the title changes; every version is kept.`
|
|
116
121
|
: `Unchanged: the page is the same as ${name} (artifact ${artifact.id}), already in the chat.`,
|
|
117
122
|
},
|
|
118
123
|
],
|
|
@@ -191,7 +196,7 @@ export function visualsTools(
|
|
|
191
196
|
.string()
|
|
192
197
|
.optional()
|
|
193
198
|
.describe(
|
|
194
|
-
"
|
|
199
|
+
"When revising a page you already showed, pass its artifact id, even if the title changes, to show a new version of it. Without it a page is matched by title or similar content, and otherwise saved as a new page. Every version is kept."
|
|
195
200
|
),
|
|
196
201
|
note: z
|
|
197
202
|
.string()
|
|
@@ -207,7 +212,7 @@ export function visualsTools(
|
|
|
207
212
|
|
|
208
213
|
export const VISUALS_BRIEF = `## Showing visuals
|
|
209
214
|
|
|
210
|
-
When a chart, table, diagram or mockup would say more than prose, build a self-contained HTML page (a chart is an HTML page; a CDN library such as Chart.js is fine), check it with \`html_preview\` and fix what the screenshot and console show, then show it with \`html_render\` before your final reply. The reader sees the page above that reply, so add only what the page doesn't say. An SVG or image file you write shows inline when your reply links to it by its absolute path, as .`;
|
|
215
|
+
When a chart, table, diagram or mockup would say more than prose, build a self-contained HTML page (a chart is an HTML page; a CDN library such as Chart.js is fine), check it with \`html_preview\` and fix what the screenshot and console show, then show it with \`html_render\` before your final reply. The reader sees the page above that reply, so add only what the page doesn't say. To revise a page you already showed, call \`html_render\` again with \`update\` set to its artifact id (even if the title changes), so it becomes the next version, not a second page. An SVG or image file you write shows inline when your reply links to it by its absolute path, as .`;
|
|
211
216
|
|
|
212
217
|
// An orchestrator reads other sessions' text, so it gets the short form and
|
|
213
218
|
// is told what to keep out of a page.
|
|
@@ -66,8 +66,9 @@ export function pageVersions(
|
|
|
66
66
|
const versions = listVersions(a.page_id).map(toVersion);
|
|
67
67
|
return {
|
|
68
68
|
pageId: a.page_id,
|
|
69
|
+
title: versions[versions.length - 1]?.title ?? a.title,
|
|
69
70
|
latestId: versions[versions.length - 1]?.id ?? a.id,
|
|
70
71
|
versions,
|
|
71
|
-
actions: pageActions(ctx),
|
|
72
|
+
actions: pageActions({ ...ctx, versions: versions.length }),
|
|
72
73
|
};
|
|
73
74
|
}
|