@coframe-gtm/annotations 1.0.1
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 +48 -0
- package/package.json +43 -0
- package/src/README.md +79 -0
- package/src/api.test.ts +253 -0
- package/src/api.ts +264 -0
- package/src/bundle.ts +69 -0
- package/src/capture.test.ts +88 -0
- package/src/capture.ts +345 -0
- package/src/index.ts +45 -0
- package/src/inject/build.ts +52 -0
- package/src/inject/bundle-source.generated.ts +5 -0
- package/src/inject/install.test.ts +84 -0
- package/src/inject/install.ts +126 -0
- package/src/output.ts +171 -0
- package/src/picker.ts +203 -0
- package/src/server/index.ts +28 -0
- package/src/server/ingest.test.ts +144 -0
- package/src/server/ingest.ts +175 -0
- package/src/server/run-store.test.ts +51 -0
- package/src/server/run-store.ts +155 -0
- package/src/shadow.ts +84 -0
- package/src/store.ts +79 -0
- package/src/types.ts +154 -0
- package/src/ui/App.ts +516 -0
- package/src/ui/styles.ts +283 -0
- package/src/ulid.ts +21 -0
- package/src/webhook.ts +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @coframe-gtm/annotations
|
|
2
|
+
|
|
3
|
+
Clean-room **AFS v1.1** annotation overlay + transport-agnostic ingest core.
|
|
4
|
+
Injects into any web page; humans and agents annotate the same page
|
|
5
|
+
bidirectionally. No platform deps — `preact` + `@preact/signals` only — so it
|
|
6
|
+
ports anywhere.
|
|
7
|
+
|
|
8
|
+
## Layout
|
|
9
|
+
|
|
10
|
+
- `src/` — **frontend**: closed Shadow DOM overlay (element / text / multi
|
|
11
|
+
picker, pins, composer, discussion threads), forensic capture, 4-tier
|
|
12
|
+
Markdown output, and the `window.__annotations` JS bridge.
|
|
13
|
+
- `src/server/` — **backend (portable)**: `parseEnvelope`, `annotationQueueReducer`,
|
|
14
|
+
`corsHeaders`, and a `RunStore` port. Wrap in a Cloudflare Worker route or an
|
|
15
|
+
AWS Lambda — same logic, no platform import.
|
|
16
|
+
- `src/inject/` — CDP install helpers + the esbuild bundler that emits the IIFE
|
|
17
|
+
and regenerates `bundle-source.generated.ts`.
|
|
18
|
+
|
|
19
|
+
## Use
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm install
|
|
23
|
+
pnpm test # vitest (node + jsdom)
|
|
24
|
+
pnpm typecheck
|
|
25
|
+
pnpm build # → dist/annotations-v1.iife.js + regenerated bundle-source
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Inject the built IIFE into a page (via CDP `Page.addScriptToEvaluateOnNewDocument`
|
|
29
|
+
or a `<script>`), then control it:
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
window.__annotations.__init({
|
|
33
|
+
webhookUrl: "https://your-host/annotations/webhook",
|
|
34
|
+
sessionId: "sess_123",
|
|
35
|
+
author: { kind: "human", displayName: "You" },
|
|
36
|
+
});
|
|
37
|
+
window.__annotations.addAnnotation({ comment: "tighten this", elementPath: "button.cta", element: "button", x: 50, y: 100 });
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The overlay POSTs AFS event envelopes (`{type, timestamp, sessionId, sequence, payload}`)
|
|
41
|
+
to `webhookUrl`. Your backend feeds them through `src/server` into whatever
|
|
42
|
+
store/queue you want.
|
|
43
|
+
|
|
44
|
+
## Two layers, one package
|
|
45
|
+
|
|
46
|
+
- **Engine** (this package) — the reusable overlay + portable ingest core.
|
|
47
|
+
- **Backend** — wire `src/server` into a Cloudflare Worker or AWS Lambda over
|
|
48
|
+
any S3 / DynamoDB / queue store you own. Nothing here pins you to a platform.
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coframe-gtm/annotations",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Clean-room AFS v1.1 annotation overlay (frontend) + transport-agnostic ingest core (backend). Injects into any page; humans + agents annotate bidirectionally.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/Coframe/GTM-growth-engine.git",
|
|
9
|
+
"directory": "coframe-agentation/frontend"
|
|
10
|
+
},
|
|
11
|
+
"main": "./src/index.ts",
|
|
12
|
+
"files": ["src", "README.md"],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./server": "./src/server/index.ts",
|
|
19
|
+
"./inject": "./src/inject/install.ts",
|
|
20
|
+
"./v1": "./src/index.ts",
|
|
21
|
+
"./v1/server": "./src/server/index.ts",
|
|
22
|
+
"./v1/inject": "./src/inject/install.ts"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"build": "node --experimental-strip-types src/inject/build.ts"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"preact": "^10.24.0",
|
|
31
|
+
"@preact/signals": "^2.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.10.5",
|
|
35
|
+
"esbuild": "^0.28.0",
|
|
36
|
+
"jsdom": "^25.0.0",
|
|
37
|
+
"typescript": "^5.9.2",
|
|
38
|
+
"vitest": "^4.1.5"
|
|
39
|
+
},
|
|
40
|
+
"pnpm": {
|
|
41
|
+
"onlyBuiltDependencies": ["esbuild"]
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# `@coframe-gtm/annotations/v1` — P1 scaffold
|
|
2
|
+
|
|
3
|
+
The clean-room injection bundle described in
|
|
4
|
+
[`../../PLAN.md`](../../PLAN.md). P1 (phase one) of that plan ships
|
|
5
|
+
this scaffold:
|
|
6
|
+
|
|
7
|
+
- Closed Shadow DOM mount (`shadow.ts`)
|
|
8
|
+
- Preact-rendered debug shell (`ui/App.ts`)
|
|
9
|
+
- `window.__annotations` API surface (`api.ts`)
|
|
10
|
+
- Outbound webhook emitter (`webhook.ts`)
|
|
11
|
+
- CDP install helpers (`inject/install.ts`)
|
|
12
|
+
- esbuild build pipeline (`inject/build.ts`)
|
|
13
|
+
|
|
14
|
+
What it does today: inject the bundle into any page, see a small
|
|
15
|
+
floating "Annotations · 0 · view mode" badge in the bottom
|
|
16
|
+
left. Call `window.__annotations.addAnnotation({…})` from the
|
|
17
|
+
console to add a row; the badge updates live.
|
|
18
|
+
|
|
19
|
+
What it doesn't do yet: pin rendering on the target element, the
|
|
20
|
+
element picker, threading UI, settings panel, forensic extraction.
|
|
21
|
+
Those land in P2 → P7.
|
|
22
|
+
|
|
23
|
+
## Build
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pnpm build
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Outputs `dist/annotations-v1.iife.js` and regenerates
|
|
30
|
+
`src/v1/inject/bundle-source.generated.ts` with the bundle as a
|
|
31
|
+
TypeScript string constant.
|
|
32
|
+
|
|
33
|
+
## Manual smoke test
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
// 1. Build the bundle.
|
|
37
|
+
// 2. Open any page (e.g. https://linear.app) in Chrome.
|
|
38
|
+
// 3. Open DevTools → Sources → paste the contents of
|
|
39
|
+
// dist/annotations-v1.iife.js, run it.
|
|
40
|
+
// 4. Verify the floating annotations badge appears.
|
|
41
|
+
// 5. Drop an annotation:
|
|
42
|
+
window.__annotations.addAnnotation({
|
|
43
|
+
kind: "feedback",
|
|
44
|
+
target: { type: "element", path: "h1" },
|
|
45
|
+
body: [{ kind: "markdown", text: "Headline test" }],
|
|
46
|
+
});
|
|
47
|
+
// 6. Badge shows "1 annotation"; the row lists "feedback · Agent".
|
|
48
|
+
// 7. Set the webhook URL and watch events POST:
|
|
49
|
+
window.__annotations.__init({ webhookUrl: "https://webhook.site/your-id" });
|
|
50
|
+
window.__annotations.addAnnotation({
|
|
51
|
+
kind: "bug",
|
|
52
|
+
target: { type: "element", path: "button" },
|
|
53
|
+
body: [{ kind: "markdown", text: "Broken on mobile" }],
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Layout
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
src/v1/
|
|
61
|
+
├── README.md # this file
|
|
62
|
+
├── index.ts # public Node surface (install helpers, types)
|
|
63
|
+
├── types.ts # data model
|
|
64
|
+
├── store.ts # Preact signals
|
|
65
|
+
├── ulid.ts # tiny inlined ULID
|
|
66
|
+
├── shadow.ts # closed Shadow DOM mount
|
|
67
|
+
├── webhook.ts # outbound POST emitter
|
|
68
|
+
├── api.ts # window.__annotations
|
|
69
|
+
├── bundle.ts # IIFE entry
|
|
70
|
+
├── ui/
|
|
71
|
+
│ └── App.ts # root Preact tree (debug shell for P1)
|
|
72
|
+
└── inject/
|
|
73
|
+
├── install.ts # CDP install helpers
|
|
74
|
+
└── build.ts # esbuild build script
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Phases P2 → P7 fill in `ui/Pin.ts`, `ui/ElementPicker.ts`,
|
|
78
|
+
`ui/CommentPopup.ts`, `ui/SettingsPanel.ts`, plus the
|
|
79
|
+
`extract.ts` / `selectors.ts` / `react.ts` modules.
|
package/src/api.test.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for the v1 API surface — AFS 1.1 shape.
|
|
3
|
+
*
|
|
4
|
+
* Confirms store mutations, event payloads, and ordering match the
|
|
5
|
+
* contract in PLAN.md. Does not exercise the shadow / Preact render
|
|
6
|
+
* path (that needs jsdom + a separate setup).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { api, initSession } from "./api.js";
|
|
12
|
+
import {
|
|
13
|
+
annotations,
|
|
14
|
+
cursors,
|
|
15
|
+
mode,
|
|
16
|
+
sequence,
|
|
17
|
+
sessionId,
|
|
18
|
+
theme,
|
|
19
|
+
webhookUrl,
|
|
20
|
+
} from "./store.js";
|
|
21
|
+
import { formatAnnotation, formatAnnotationBundle } from "./output.js";
|
|
22
|
+
import type { AgentationEvent, Annotation } from "./types.js";
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
annotations.value = [];
|
|
26
|
+
mode.value = "view";
|
|
27
|
+
theme.value = "dark";
|
|
28
|
+
webhookUrl.value = null;
|
|
29
|
+
sessionId.value = "test-session";
|
|
30
|
+
sequence.value = 0;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("api.addAnnotation (AFS shape)", () => {
|
|
34
|
+
it("emits AFS 1.1-shaped annotations", () => {
|
|
35
|
+
const id = api.addAnnotation({
|
|
36
|
+
comment: "**Headline** is buried",
|
|
37
|
+
elementPath: "body > main > h1",
|
|
38
|
+
element: "h1",
|
|
39
|
+
x: 50,
|
|
40
|
+
y: 200,
|
|
41
|
+
});
|
|
42
|
+
expect(id).toMatch(/^ann_/);
|
|
43
|
+
const a = annotations.value[0]!;
|
|
44
|
+
expect(a.element).toBe("h1");
|
|
45
|
+
expect(a.elementPath).toBe("body > main > h1");
|
|
46
|
+
expect(a.kind).toBe("feedback");
|
|
47
|
+
expect(a.status).toBe("pending");
|
|
48
|
+
expect(a.x).toBe(50);
|
|
49
|
+
expect(a.y).toBe(200);
|
|
50
|
+
expect(a.thread).toEqual([]);
|
|
51
|
+
expect(a.timestamp).toBeGreaterThan(0);
|
|
52
|
+
expect(typeof a.createdAt).toBe("string");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("respects custom kind / intent / severity / author", () => {
|
|
56
|
+
api.addAnnotation({
|
|
57
|
+
comment: "broken on mobile",
|
|
58
|
+
elementPath: "button.cta",
|
|
59
|
+
element: "button",
|
|
60
|
+
x: 25,
|
|
61
|
+
y: 480,
|
|
62
|
+
kind: "feedback",
|
|
63
|
+
intent: "fix",
|
|
64
|
+
severity: "blocking",
|
|
65
|
+
author: { kind: "human", id: "ari", displayName: "Ari" },
|
|
66
|
+
});
|
|
67
|
+
const a = annotations.value[0]!;
|
|
68
|
+
expect(a.intent).toBe("fix");
|
|
69
|
+
expect(a.severity).toBe("blocking");
|
|
70
|
+
expect(a.author?.kind).toBe("human");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("threading and lifecycle", () => {
|
|
75
|
+
it("acknowledge → resolve flow", () => {
|
|
76
|
+
const id = api.addAnnotation({
|
|
77
|
+
comment: "fix nav density",
|
|
78
|
+
elementPath: "nav",
|
|
79
|
+
element: "nav",
|
|
80
|
+
x: 50,
|
|
81
|
+
y: 80,
|
|
82
|
+
});
|
|
83
|
+
expect(api.acknowledgeAnnotation(id)).toBe(true);
|
|
84
|
+
expect(annotations.value[0]!.status).toBe("acknowledged");
|
|
85
|
+
expect(api.resolveAnnotation(id, "agent")).toBe(true);
|
|
86
|
+
expect(annotations.value[0]!.status).toBe("resolved");
|
|
87
|
+
expect(annotations.value[0]!.resolvedBy).toBe("agent");
|
|
88
|
+
expect(typeof annotations.value[0]!.resolvedAt).toBe("string");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("dismiss sets dismissed status", () => {
|
|
92
|
+
const id = api.addAnnotation({
|
|
93
|
+
comment: "ok skip",
|
|
94
|
+
elementPath: "footer",
|
|
95
|
+
element: "footer",
|
|
96
|
+
x: 50,
|
|
97
|
+
y: 1200,
|
|
98
|
+
});
|
|
99
|
+
expect(api.dismissAnnotation(id)).toBe(true);
|
|
100
|
+
expect(annotations.value[0]!.status).toBe("dismissed");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("replyToAnnotation appends thread message", () => {
|
|
104
|
+
const id = api.addAnnotation({
|
|
105
|
+
comment: "ok",
|
|
106
|
+
elementPath: "h1",
|
|
107
|
+
element: "h1",
|
|
108
|
+
x: 50,
|
|
109
|
+
y: 100,
|
|
110
|
+
});
|
|
111
|
+
const msgId = api.replyToAnnotation(id, {
|
|
112
|
+
role: "agent",
|
|
113
|
+
content: "Try `Start free →`",
|
|
114
|
+
});
|
|
115
|
+
expect(msgId).toMatch(/^msg_/);
|
|
116
|
+
const a = annotations.value[0]!;
|
|
117
|
+
expect(a.thread).toHaveLength(1);
|
|
118
|
+
expect(a.thread![0]!.role).toBe("agent");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("replyToAnnotation returns null for unknown ids", () => {
|
|
122
|
+
expect(api.replyToAnnotation("missing", { role: "human", content: "x" })).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("clearAnnotations / removeAnnotation", () => {
|
|
127
|
+
it("clear returns count and empties", () => {
|
|
128
|
+
api.addAnnotation({ comment: "a", elementPath: "h1", element: "h1", x: 0, y: 0 });
|
|
129
|
+
api.addAnnotation({ comment: "b", elementPath: "h2", element: "h2", x: 0, y: 0 });
|
|
130
|
+
expect(api.clearAnnotations()).toBe(2);
|
|
131
|
+
expect(annotations.value).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("webhook emission", () => {
|
|
136
|
+
it("emits AFS event envelope on addAnnotation", async () => {
|
|
137
|
+
const fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
|
138
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
139
|
+
initSession({ webhookUrl: "https://example.com/webhook", sessionId: "s1" });
|
|
140
|
+
|
|
141
|
+
api.addAnnotation({
|
|
142
|
+
comment: "test",
|
|
143
|
+
elementPath: "h1",
|
|
144
|
+
element: "h1",
|
|
145
|
+
x: 50,
|
|
146
|
+
y: 100,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await Promise.resolve();
|
|
150
|
+
// 2 emits: session.created (initSession) + annotation.created.
|
|
151
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
152
|
+
const lastCall = fetchSpy.mock.calls[1]!;
|
|
153
|
+
const body = JSON.parse((lastCall[1] as { body: string }).body) as AgentationEvent;
|
|
154
|
+
expect(body.type).toBe("annotation.created");
|
|
155
|
+
expect(body.sessionId).toBe("s1");
|
|
156
|
+
expect(body.sequence).toBeGreaterThan(0);
|
|
157
|
+
expect(typeof body.timestamp).toBe("string");
|
|
158
|
+
vi.unstubAllGlobals();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("monotonic sequence per session", () => {
|
|
162
|
+
initSession({ webhookUrl: null as unknown as string, sessionId: "s2" });
|
|
163
|
+
const seqStart = sequence.value;
|
|
164
|
+
api.addAnnotation({ comment: "a", elementPath: "h1", element: "h1", x: 0, y: 0 });
|
|
165
|
+
const id = annotations.value[0]!.id;
|
|
166
|
+
api.replyToAnnotation(id, { role: "agent", content: "ack" });
|
|
167
|
+
api.acknowledgeAnnotation(id);
|
|
168
|
+
expect(sequence.value).toBeGreaterThan(seqStart);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("formatAnnotation (Forensic output)", () => {
|
|
173
|
+
const sample: Annotation = {
|
|
174
|
+
id: "ann_k8x2m",
|
|
175
|
+
comment: "Button is cut off on mobile viewport",
|
|
176
|
+
elementPath: "body > main > .hero-section > button.cta",
|
|
177
|
+
timestamp: 1705694400000,
|
|
178
|
+
x: 45.5,
|
|
179
|
+
y: 480,
|
|
180
|
+
element: "button",
|
|
181
|
+
url: "http://localhost:3000/landing",
|
|
182
|
+
boundingBox: { x: 120, y: 480, width: 200, height: 48 },
|
|
183
|
+
reactComponents: "App > LandingPage > HeroSection > CTAButton",
|
|
184
|
+
cssClasses: "cta btn-primary",
|
|
185
|
+
nearbyText: "Get Started Free",
|
|
186
|
+
intent: "fix",
|
|
187
|
+
severity: "blocking",
|
|
188
|
+
status: "pending",
|
|
189
|
+
thread: [
|
|
190
|
+
{ id: "msg_1", role: "human", content: "blocks 50% of users", timestamp: 1 },
|
|
191
|
+
{ id: "msg_2", role: "agent", content: "fixing in next variant", timestamp: 2 },
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
it("forensic includes the full field set", () => {
|
|
196
|
+
const md = formatAnnotation(sample, { detail: "forensic", index: 1 });
|
|
197
|
+
expect(md).toContain("# Annotation");
|
|
198
|
+
expect(md).toContain("Annotation #1");
|
|
199
|
+
expect(md).toContain("**Element:** button");
|
|
200
|
+
expect(md).toContain("**Path:**");
|
|
201
|
+
expect(md).toContain("**React:** App > LandingPage");
|
|
202
|
+
expect(md).toContain("**Severity:**");
|
|
203
|
+
expect(md).toContain("Intent");
|
|
204
|
+
expect(md).toContain("**Nearby text:**");
|
|
205
|
+
expect(md).toContain("**Thread:**");
|
|
206
|
+
expect(md).toContain("agent");
|
|
207
|
+
expect(md).toContain("Button is cut off on mobile viewport");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("compact strips contextual fields", () => {
|
|
211
|
+
const md = formatAnnotation(sample, { detail: "compact" });
|
|
212
|
+
expect(md).not.toContain("**React:**");
|
|
213
|
+
expect(md).not.toContain("**Nearby text:**");
|
|
214
|
+
expect(md).toContain("**Element:**");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("bundle formats N annotations with a header", () => {
|
|
218
|
+
const md = formatAnnotationBundle([sample, sample], {
|
|
219
|
+
pageUrl: "https://stripe.com",
|
|
220
|
+
sessionId: "s1",
|
|
221
|
+
});
|
|
222
|
+
expect(md.startsWith("# Annotations")).toBe(true);
|
|
223
|
+
expect(md).toContain("URL: https://stripe.com");
|
|
224
|
+
expect(md).toContain("Count: 2");
|
|
225
|
+
expect(md.match(/^## Annotation/gm) ?? []).toHaveLength(2);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("presence cursors (multi-cursor)", () => {
|
|
230
|
+
beforeEach(() => api.clearCursors());
|
|
231
|
+
|
|
232
|
+
it("setCursors replaces all; setCursor upserts by id; removeCursor drops one", () => {
|
|
233
|
+
api.setCursors([
|
|
234
|
+
{ id: "you", label: "You", kind: "human", x: 30, y: 100 },
|
|
235
|
+
{ id: "agent", label: "Agent", kind: "agent", x: 50, y: 200 },
|
|
236
|
+
]);
|
|
237
|
+
expect(cursors.value.map((c) => c.id)).toEqual(["you", "agent"]);
|
|
238
|
+
|
|
239
|
+
api.setCursor({ id: "agent", label: "Agent", kind: "agent", x: 55, y: 210 });
|
|
240
|
+
expect(cursors.value).toHaveLength(2);
|
|
241
|
+
expect(cursors.value.find((c) => c.id === "agent")?.x).toBe(55);
|
|
242
|
+
|
|
243
|
+
api.setCursor({ id: "scout", label: "Scout", kind: "agent", x: 70, y: 150 });
|
|
244
|
+
expect(cursors.value).toHaveLength(3);
|
|
245
|
+
|
|
246
|
+
expect(api.removeCursor("you")).toBe(true);
|
|
247
|
+
expect(api.removeCursor("ghost")).toBe(false);
|
|
248
|
+
expect(cursors.value).toHaveLength(2);
|
|
249
|
+
|
|
250
|
+
api.clearCursors();
|
|
251
|
+
expect(cursors.value).toHaveLength(0);
|
|
252
|
+
});
|
|
253
|
+
});
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `window.__annotations` — the inbound JS bridge.
|
|
3
|
+
*
|
|
4
|
+
* AFS 1.1 compatible: every annotation is the flat shape from
|
|
5
|
+
* `./types.ts`. Callers (CDP injectors, in-page UI, agents) hit
|
|
6
|
+
* these methods to mutate the store; the store emits the
|
|
7
|
+
* corresponding `annotation.created / updated / deleted` event.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ulid } from "./ulid.js";
|
|
11
|
+
import {
|
|
12
|
+
activeThreadId,
|
|
13
|
+
annotations,
|
|
14
|
+
cursors,
|
|
15
|
+
mode,
|
|
16
|
+
sessionId,
|
|
17
|
+
theme,
|
|
18
|
+
webhookUrl,
|
|
19
|
+
type PresenceCursor,
|
|
20
|
+
} from "./store.js";
|
|
21
|
+
import { emit } from "./webhook.js";
|
|
22
|
+
import type {
|
|
23
|
+
Annotation,
|
|
24
|
+
AnnotationKind,
|
|
25
|
+
AnnotationStatus,
|
|
26
|
+
Mode,
|
|
27
|
+
Theme,
|
|
28
|
+
ThreadMessage,
|
|
29
|
+
} from "./types.js";
|
|
30
|
+
|
|
31
|
+
export interface AddAnnotationInput {
|
|
32
|
+
/** Generated if omitted. */
|
|
33
|
+
id?: string;
|
|
34
|
+
/** Markdown. */
|
|
35
|
+
comment: string;
|
|
36
|
+
elementPath: string;
|
|
37
|
+
element: string;
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
timestamp?: number;
|
|
41
|
+
url?: string;
|
|
42
|
+
boundingBox?: Annotation["boundingBox"];
|
|
43
|
+
reactComponents?: string;
|
|
44
|
+
cssClasses?: string;
|
|
45
|
+
computedStyles?: string;
|
|
46
|
+
accessibility?: string;
|
|
47
|
+
nearbyText?: string;
|
|
48
|
+
selectedText?: string;
|
|
49
|
+
isFixed?: boolean;
|
|
50
|
+
isMultiSelect?: boolean;
|
|
51
|
+
fullPath?: string;
|
|
52
|
+
nearbyElements?: string;
|
|
53
|
+
elementBoundingBoxes?: Annotation["elementBoundingBoxes"];
|
|
54
|
+
intent?: Annotation["intent"];
|
|
55
|
+
severity?: Annotation["severity"];
|
|
56
|
+
kind?: AnnotationKind;
|
|
57
|
+
placement?: Annotation["placement"];
|
|
58
|
+
rearrange?: Annotation["rearrange"];
|
|
59
|
+
status?: AnnotationStatus;
|
|
60
|
+
author?: Annotation["author"];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ReplyInput {
|
|
64
|
+
role: "human" | "agent";
|
|
65
|
+
/** Markdown. */
|
|
66
|
+
content: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AnnotationsApi {
|
|
70
|
+
readonly version: string;
|
|
71
|
+
readonly schema: "afs-1.1";
|
|
72
|
+
addAnnotation(input: AddAnnotationInput): string;
|
|
73
|
+
replyToAnnotation(annotationId: string, message: ReplyInput): string | null;
|
|
74
|
+
updateAnnotation(annotationId: string, patch: Partial<Annotation>): boolean;
|
|
75
|
+
acknowledgeAnnotation(annotationId: string): boolean;
|
|
76
|
+
resolveAnnotation(annotationId: string, by?: "human" | "agent"): boolean;
|
|
77
|
+
dismissAnnotation(annotationId: string): boolean;
|
|
78
|
+
removeAnnotation(annotationId: string): boolean;
|
|
79
|
+
clearAnnotations(): number;
|
|
80
|
+
/** Open an annotation's thread panel in the UI. Returns false if not found. */
|
|
81
|
+
focusAnnotation(annotationId: string): boolean;
|
|
82
|
+
/** Close any open thread panel. */
|
|
83
|
+
closeThread(): void;
|
|
84
|
+
/** Replace all live presence cursors (multi-cursor collaboration). */
|
|
85
|
+
setCursors(next: PresenceCursor[]): void;
|
|
86
|
+
/** Upsert a single actor's cursor by id. */
|
|
87
|
+
setCursor(cursor: PresenceCursor): void;
|
|
88
|
+
/** Remove one actor's cursor (e.g. an agent finished / a human left). */
|
|
89
|
+
removeCursor(id: string): boolean;
|
|
90
|
+
/** Remove all cursors. */
|
|
91
|
+
clearCursors(): void;
|
|
92
|
+
setMode(next: Mode): void;
|
|
93
|
+
setTheme(next: Theme): void;
|
|
94
|
+
getAnnotations(): Annotation[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const api: AnnotationsApi = {
|
|
98
|
+
version: "0.1.0",
|
|
99
|
+
schema: "afs-1.1",
|
|
100
|
+
|
|
101
|
+
addAnnotation(input) {
|
|
102
|
+
const id = input.id ?? `ann_${ulid().slice(-12).toLowerCase()}`;
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
const annotation: Annotation = {
|
|
105
|
+
id,
|
|
106
|
+
comment: input.comment,
|
|
107
|
+
elementPath: input.elementPath,
|
|
108
|
+
element: input.element,
|
|
109
|
+
x: input.x,
|
|
110
|
+
y: input.y,
|
|
111
|
+
timestamp: input.timestamp ?? now,
|
|
112
|
+
url: input.url,
|
|
113
|
+
boundingBox: input.boundingBox,
|
|
114
|
+
reactComponents: input.reactComponents,
|
|
115
|
+
cssClasses: input.cssClasses,
|
|
116
|
+
computedStyles: input.computedStyles,
|
|
117
|
+
accessibility: input.accessibility,
|
|
118
|
+
nearbyText: input.nearbyText,
|
|
119
|
+
selectedText: input.selectedText,
|
|
120
|
+
isFixed: input.isFixed,
|
|
121
|
+
isMultiSelect: input.isMultiSelect,
|
|
122
|
+
fullPath: input.fullPath,
|
|
123
|
+
nearbyElements: input.nearbyElements,
|
|
124
|
+
elementBoundingBoxes: input.elementBoundingBoxes,
|
|
125
|
+
intent: input.intent,
|
|
126
|
+
severity: input.severity,
|
|
127
|
+
kind: input.kind ?? "feedback",
|
|
128
|
+
placement: input.placement,
|
|
129
|
+
rearrange: input.rearrange,
|
|
130
|
+
status: input.status ?? "pending",
|
|
131
|
+
thread: [],
|
|
132
|
+
author: input.author ?? {
|
|
133
|
+
kind: "agent",
|
|
134
|
+
id: "agent",
|
|
135
|
+
displayName: "Agent",
|
|
136
|
+
},
|
|
137
|
+
createdAt: new Date(now).toISOString(),
|
|
138
|
+
updatedAt: new Date(now).toISOString(),
|
|
139
|
+
};
|
|
140
|
+
annotations.value = [...annotations.value, annotation];
|
|
141
|
+
emit("annotation.created", annotation);
|
|
142
|
+
return id;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
replyToAnnotation(annotationId, message) {
|
|
146
|
+
const existing = annotations.value.find((a) => a.id === annotationId);
|
|
147
|
+
if (!existing) return null;
|
|
148
|
+
const msg: ThreadMessage = {
|
|
149
|
+
id: `msg_${ulid().slice(-10).toLowerCase()}`,
|
|
150
|
+
role: message.role,
|
|
151
|
+
content: message.content,
|
|
152
|
+
timestamp: Date.now(),
|
|
153
|
+
};
|
|
154
|
+
annotations.value = annotations.value.map((a) =>
|
|
155
|
+
a.id === annotationId
|
|
156
|
+
? {
|
|
157
|
+
...a,
|
|
158
|
+
thread: [...(a.thread ?? []), msg],
|
|
159
|
+
updatedAt: new Date().toISOString(),
|
|
160
|
+
}
|
|
161
|
+
: a,
|
|
162
|
+
);
|
|
163
|
+
emit("thread.message", { annotationId, message: msg });
|
|
164
|
+
return msg.id;
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
updateAnnotation(annotationId, patch) {
|
|
168
|
+
let found = false;
|
|
169
|
+
annotations.value = annotations.value.map((a) => {
|
|
170
|
+
if (a.id !== annotationId) return a;
|
|
171
|
+
found = true;
|
|
172
|
+
return { ...a, ...patch, updatedAt: new Date().toISOString() } as Annotation;
|
|
173
|
+
});
|
|
174
|
+
if (found) emit("annotation.updated", { id: annotationId, patch });
|
|
175
|
+
return found;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
acknowledgeAnnotation(annotationId) {
|
|
179
|
+
return this.updateAnnotation(annotationId, { status: "acknowledged" });
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
resolveAnnotation(annotationId, by = "agent") {
|
|
183
|
+
return this.updateAnnotation(annotationId, {
|
|
184
|
+
status: "resolved",
|
|
185
|
+
resolvedAt: new Date().toISOString(),
|
|
186
|
+
resolvedBy: by,
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
dismissAnnotation(annotationId) {
|
|
191
|
+
return this.updateAnnotation(annotationId, { status: "dismissed" });
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
removeAnnotation(annotationId) {
|
|
195
|
+
const before = annotations.value.length;
|
|
196
|
+
annotations.value = annotations.value.filter((a) => a.id !== annotationId);
|
|
197
|
+
const removed = annotations.value.length < before;
|
|
198
|
+
if (removed) emit("annotation.deleted", { id: annotationId });
|
|
199
|
+
return removed;
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
clearAnnotations() {
|
|
203
|
+
const n = annotations.value.length;
|
|
204
|
+
annotations.value = [];
|
|
205
|
+
activeThreadId.value = null;
|
|
206
|
+
emit("session.updated", { cleared: n });
|
|
207
|
+
return n;
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
focusAnnotation(annotationId) {
|
|
211
|
+
const exists = annotations.value.some((a) => a.id === annotationId);
|
|
212
|
+
if (exists) activeThreadId.value = annotationId;
|
|
213
|
+
return exists;
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
closeThread() {
|
|
217
|
+
activeThreadId.value = null;
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
setCursors(next) {
|
|
221
|
+
cursors.value = next;
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
setCursor(cursor) {
|
|
225
|
+
const others = cursors.value.filter((c) => c.id !== cursor.id);
|
|
226
|
+
cursors.value = [...others, cursor];
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
removeCursor(id) {
|
|
230
|
+
const before = cursors.value.length;
|
|
231
|
+
cursors.value = cursors.value.filter((c) => c.id !== id);
|
|
232
|
+
return cursors.value.length < before;
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
clearCursors() {
|
|
236
|
+
cursors.value = [];
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
setMode(next) {
|
|
240
|
+
if (mode.value === next) return;
|
|
241
|
+
mode.value = next;
|
|
242
|
+
emit("session.updated", { mode: next });
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
setTheme(next) {
|
|
246
|
+
theme.value = next;
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
getAnnotations() {
|
|
250
|
+
return annotations.value;
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export function initSession(opts: {
|
|
255
|
+
webhookUrl?: string;
|
|
256
|
+
sessionId?: string;
|
|
257
|
+
}): void {
|
|
258
|
+
if (opts.webhookUrl !== undefined) webhookUrl.value = opts.webhookUrl;
|
|
259
|
+
sessionId.value = opts.sessionId ?? `cf_${ulid().slice(-12).toLowerCase()}`;
|
|
260
|
+
emit("session.created", {
|
|
261
|
+
sessionId: sessionId.value,
|
|
262
|
+
pageUrl: typeof location !== "undefined" ? location.href : "",
|
|
263
|
+
});
|
|
264
|
+
}
|