visual-comments 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +477 -0
- package/dist/core/aim.d.ts +21 -0
- package/dist/core/aim.js +21 -0
- package/dist/core/api.d.ts +5 -0
- package/dist/core/api.js +11 -0
- package/dist/core/metaball.d.ts +15 -0
- package/dist/core/metaball.js +42 -0
- package/dist/core/presence.d.ts +13 -0
- package/dist/core/presence.js +25 -0
- package/dist/core/surfaces.d.ts +95 -0
- package/dist/core/surfaces.js +197 -0
- package/dist/core/target.d.ts +59 -0
- package/dist/core/target.js +382 -0
- package/dist/core/threads.svelte.d.ts +80 -0
- package/dist/core/threads.svelte.js +336 -0
- package/dist/core/time.d.ts +3 -0
- package/dist/core/time.js +25 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/server/env.d.ts +78 -0
- package/dist/server/env.js +94 -0
- package/dist/server/index.d.ts +34 -0
- package/dist/server/index.js +74 -0
- package/dist/server/variables.d.ts +28 -0
- package/dist/server/variables.js +73 -0
- package/dist/shared/limits.d.ts +9 -0
- package/dist/shared/limits.js +10 -0
- package/dist/shared/protocol.d.ts +6 -0
- package/dist/shared/protocol.js +7 -0
- package/dist/shared/types.d.ts +157 -0
- package/dist/shared/types.js +2 -0
- package/dist/sveltekit/CommentTools.svelte +98 -0
- package/dist/sveltekit/CommentTools.svelte.d.ts +6 -0
- package/dist/sveltekit/Comments.svelte +104 -0
- package/dist/sveltekit/Comments.svelte.d.ts +25 -0
- package/dist/sveltekit/Toolbar.svelte +64 -0
- package/dist/sveltekit/Toolbar.svelte.d.ts +13 -0
- package/dist/sveltekit/ToolbarButton.svelte +66 -0
- package/dist/sveltekit/ToolbarButton.svelte.d.ts +12 -0
- package/dist/sveltekit/context.d.ts +16 -0
- package/dist/sveltekit/context.js +9 -0
- package/dist/sveltekit/index.d.ts +6 -0
- package/dist/sveltekit/index.js +6 -0
- package/dist/ui/Account.svelte +106 -0
- package/dist/ui/Account.svelte.d.ts +8 -0
- package/dist/ui/App.svelte +77 -0
- package/dist/ui/App.svelte.d.ts +7 -0
- package/dist/ui/Avatar.svelte +64 -0
- package/dist/ui/Avatar.svelte.d.ts +11 -0
- package/dist/ui/Composer.svelte +267 -0
- package/dist/ui/Composer.svelte.d.ts +20 -0
- package/dist/ui/Connect.svelte +128 -0
- package/dist/ui/Connect.svelte.d.ts +6 -0
- package/dist/ui/Icon.svelte +44 -0
- package/dist/ui/Icon.svelte.d.ts +29 -0
- package/dist/ui/Menu.svelte +69 -0
- package/dist/ui/Menu.svelte.d.ts +15 -0
- package/dist/ui/MenuItem.svelte +38 -0
- package/dist/ui/MenuItem.svelte.d.ts +12 -0
- package/dist/ui/Overlay.svelte +502 -0
- package/dist/ui/Overlay.svelte.d.ts +3 -0
- package/dist/ui/Panel.svelte +220 -0
- package/dist/ui/Panel.svelte.d.ts +10 -0
- package/dist/ui/Pin.svelte +156 -0
- package/dist/ui/Pin.svelte.d.ts +15 -0
- package/dist/ui/Presence.svelte +43 -0
- package/dist/ui/Presence.svelte.d.ts +8 -0
- package/dist/ui/ThreadCard.svelte +201 -0
- package/dist/ui/ThreadCard.svelte.d.ts +13 -0
- package/dist/ui/Toolbar.svelte +35 -0
- package/dist/ui/Toolbar.svelte.d.ts +18 -0
- package/dist/ui/context.d.ts +3 -0
- package/dist/ui/context.js +8 -0
- package/dist/ui/generated/styles.css +2 -0
- package/dist/ui/generated/styles.d.ts +2 -0
- package/dist/ui/generated/styles.js +3 -0
- package/dist/ui/styles.css +113 -0
- package/dist/ui/text.d.ts +5 -0
- package/dist/ui/text.js +11 -0
- package/dist/visual-comments.svelte.d.ts +230 -0
- package/dist/visual-comments.svelte.js +693 -0
- package/dist/vite/index.d.ts +20 -0
- package/dist/vite/index.js +78 -0
- package/dist-widget/visual-comments.js +58 -0
- package/package.json +86 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Convex Works
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# visual-comments
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Comments pinned to the elements of any web page or app, like a design tool's
|
|
4
|
+
comments or Vercel's preview comments. People press **C**, click the element
|
|
5
|
+
they mean, and start a thread pinned to it. One self-hosted comment server
|
|
6
|
+
holds the threads of many sites; each site signs its own people in and passes
|
|
7
|
+
their comment requests on through its own server.
|
|
8
|
+
|
|
9
|
+
This package is the browser library (Svelte 5), the server-side proxy, the
|
|
10
|
+
SvelteKit adapter, the command for coding agents, and the script-tag build. The comment server and full documentation live in the
|
|
11
|
+
[repository](https://github.com/Convex-Works/visual-comments).
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install visual-comments
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Proxy the comment requests
|
|
18
|
+
|
|
19
|
+
The site's server passes `/_comments/*` on to the comment server with the
|
|
20
|
+
space's secret and the signed-in person:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// src/hooks.server.ts
|
|
24
|
+
import { sequence } from '@sveltejs/kit/hooks';
|
|
25
|
+
import { commentsProxy } from 'visual-comments/server';
|
|
26
|
+
|
|
27
|
+
const comments = commentsProxy({
|
|
28
|
+
server: 'https://comments.example.com',
|
|
29
|
+
space: 'my-team',
|
|
30
|
+
secret: env.COMMENTS_SECRET // the space's secret; never sent to browsers
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// After the site's sign-in hook, which sets `event.locals.user`.
|
|
34
|
+
export const handle = sequence(signIn, comments.sveltekit());
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Other servers call `comments.handle(request, user)`. See the
|
|
38
|
+
[proxy protocol](https://github.com/Convex-Works/visual-comments/blob/main/docs/PROTOCOL.md).
|
|
39
|
+
|
|
40
|
+
## Add comments to the page
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { createVisualComments } from 'visual-comments';
|
|
44
|
+
|
|
45
|
+
const comments = createVisualComments({
|
|
46
|
+
endpoint: '/_comments', // the default
|
|
47
|
+
auth: '/auth',
|
|
48
|
+
show: (thread) => canvas.zoomTo(thread.surface) // return true when handled
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The library compiles with the host's Svelte (a peer dependency). Its options
|
|
53
|
+
are in the [library reference](https://github.com/Convex-Works/visual-comments/blob/main/docs/LIBRARY.md).
|
|
54
|
+
Pages without a bundler can use the script build instead, served by the
|
|
55
|
+
comment server at `/v1/visual-comments.js` and also shipped here as
|
|
56
|
+
`visual-comments/widget.js`; see the
|
|
57
|
+
[markup reference](https://github.com/Convex-Works/visual-comments/blob/main/docs/MARKUP.md).
|
|
58
|
+
|
|
59
|
+
## SvelteKit
|
|
60
|
+
|
|
61
|
+
`visual-comments/sveltekit` sets the comments up from environment variables
|
|
62
|
+
(`VISUAL_COMMENTS_ENABLED`, `_SERVER`, `_SPACE`, `_SECRET`): a hook, a layout
|
|
63
|
+
`load`, and `<Comments>` with a `<Toolbar>` at the bottom of the window. They
|
|
64
|
+
can sign people in on their own, apart from the app's accounts, and the
|
|
65
|
+
`visualComments()` Vite plugin from `visual-comments/vite` leaves them out of
|
|
66
|
+
production builds. See the
|
|
67
|
+
[SvelteKit guide](https://github.com/Convex-Works/visual-comments/blob/main/docs/SVELTEKIT.md).
|
|
68
|
+
|
|
69
|
+
## Coding agents
|
|
70
|
+
|
|
71
|
+
The package's `visual-comments` command lets a coding agent join a space:
|
|
72
|
+
`npx visual-comments connect <site>`, then `next` to wait for new comments,
|
|
73
|
+
`reply` and `presence`. See
|
|
74
|
+
[coding agents](https://github.com/Convex-Works/visual-comments/blob/main/docs/CODING-AGENTS.md).
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
// The `visual-comments` command, for coding agents: join a site's comment space
|
|
4
|
+
// once a person allows it, wait for new comments, reply, and say what you are
|
|
5
|
+
// doing. It talks only to the site's comments route (`/_comments`), with the
|
|
6
|
+
// agent's token. Node built-ins only, and compiled, because Node does not strip
|
|
7
|
+
// types in node_modules.
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { homedir, tmpdir } from 'node:os';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
13
|
+
import { parseArgs } from 'node:util';
|
|
14
|
+
const DEFAULT_PATH = '/_comments';
|
|
15
|
+
/** The server holds `/agent/next` and the token request up to 50 s; give it room. */
|
|
16
|
+
const LONG_POLL_MS = 80_000;
|
|
17
|
+
const REQUEST_MS = 30_000;
|
|
18
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
19
|
+
const IMAGE_EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' };
|
|
20
|
+
const HELP = {
|
|
21
|
+
main: `Usage: visual-comments <command> [options]
|
|
22
|
+
|
|
23
|
+
Lets a coding agent take part in a site's comments. A person allows it once in
|
|
24
|
+
the browser; then it waits for new comments, replies, and shows what it is doing.
|
|
25
|
+
|
|
26
|
+
Commands:
|
|
27
|
+
connect <site> Ask to join the site's comments, and wait for a person to allow it.
|
|
28
|
+
next Wait for new comments; print them as JSON.
|
|
29
|
+
reply <thread> Reply in a thread, with --body or stdin.
|
|
30
|
+
presence <state> Say what you are doing: viewing, working or gone.
|
|
31
|
+
agents List the space's agents.
|
|
32
|
+
disconnect Leave the space and forget the token.
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--site <site> Which connected site, when there are several. Or set VISUAL_COMMENTS_SITE.
|
|
36
|
+
-h, --help Help for a command: visual-comments <command> --help
|
|
37
|
+
|
|
38
|
+
Tokens are kept in \${XDG_CONFIG_HOME:-~/.config}/visual-comments/agents.json.
|
|
39
|
+
Exit codes: 0 done, 1 error, 2 not connected or the token was rejected.`,
|
|
40
|
+
connect: `Usage: visual-comments connect <site> [--name <name>] [--path <path>]
|
|
41
|
+
|
|
42
|
+
Asks to join the site's comments, prints the address where a person allows it
|
|
43
|
+
(and opens it in the browser), waits, then saves the token.
|
|
44
|
+
|
|
45
|
+
--name <name> The name people see on your comments. Default "Agent".
|
|
46
|
+
--path <path> Where the site answers comment requests. Default /_comments.
|
|
47
|
+
|
|
48
|
+
Example: visual-comments connect http://localhost:5173 --name Claude`,
|
|
49
|
+
next: `Usage: visual-comments next [--site <site>]
|
|
50
|
+
|
|
51
|
+
Waits, as long as it takes, until a thread has something new for you, then
|
|
52
|
+
prints { site, threads, participants } as JSON and exits. The threads are then
|
|
53
|
+
read for you. Images are downloaded; each gets a "path". Network errors are
|
|
54
|
+
retried. Run it again at once to keep listening.`,
|
|
55
|
+
reply: `Usage: visual-comments reply <thread> [--body <text>] [--site <site>]
|
|
56
|
+
|
|
57
|
+
Replies in thread #<thread>. Without --body, the text is read from stdin.
|
|
58
|
+
Prints { thread: { id, url, comment } }.
|
|
59
|
+
|
|
60
|
+
Example: echo "Done: the table is tighter." | visual-comments reply 12`,
|
|
61
|
+
presence: `Usage: visual-comments presence <viewing|working|gone> [options]
|
|
62
|
+
|
|
63
|
+
Says what you are doing, for everyone in the space to see. Each update states
|
|
64
|
+
the whole presence: options left out are cleared.
|
|
65
|
+
|
|
66
|
+
--key <key> One presence per key, to work on several things at once. Default "".
|
|
67
|
+
--thread <n> The thread it is about.
|
|
68
|
+
--surface <id> The surface (e.g. a frame) it is about.
|
|
69
|
+
--page <page> The page it is about.
|
|
70
|
+
--note <text> What you are doing, e.g. "Tightening the table".
|
|
71
|
+
--site <site> Which connected site.
|
|
72
|
+
|
|
73
|
+
Example: visual-comments presence working --key 12 --thread 12 --note "Tightening the table"
|
|
74
|
+
visual-comments presence gone --key 12`,
|
|
75
|
+
agents: `Usage: visual-comments agents [--site <site>]
|
|
76
|
+
|
|
77
|
+
Prints the space's agents as JSON: { site, me, agents }.`,
|
|
78
|
+
disconnect: `Usage: visual-comments disconnect [--site <site>]
|
|
79
|
+
|
|
80
|
+
Removes you from the space and forgets the token.`
|
|
81
|
+
};
|
|
82
|
+
/** Ends the command with a message and an exit code. */
|
|
83
|
+
class CliError extends Error {
|
|
84
|
+
code;
|
|
85
|
+
constructor(code, message) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.code = code;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** A failed request. Status 0 when the site could not be reached. */
|
|
91
|
+
class HttpError extends Error {
|
|
92
|
+
status;
|
|
93
|
+
constructor(status, message) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.status = status;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const log = (message) => process.stderr.write(`${message}\n`);
|
|
99
|
+
/** Asked for, so on stdout; usage errors go to stderr. */
|
|
100
|
+
const help = (text) => process.stdout.write(`${text}\n`);
|
|
101
|
+
const print = (value) => process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
102
|
+
function parse(command, args, options) {
|
|
103
|
+
try {
|
|
104
|
+
return parseArgs({ args, options: { ...options, help: { type: 'boolean', short: 'h' } }, allowPositionals: true, strict: true });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new CliError(1, `${error.message}\n\n${HELP[command]}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Sites and saved tokens
|
|
111
|
+
/** A site as typed: `localhost:5173` works too. */
|
|
112
|
+
function siteUrl(site) {
|
|
113
|
+
const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(site)
|
|
114
|
+
? site
|
|
115
|
+
: `${/^(localhost|127\.|\[::1\])/.test(site) ? 'http' : 'https'}://${site}`;
|
|
116
|
+
try {
|
|
117
|
+
const url = new URL(withScheme);
|
|
118
|
+
if (url.protocol === 'http:' || url.protocol === 'https:')
|
|
119
|
+
return url;
|
|
120
|
+
}
|
|
121
|
+
catch { /* below */ }
|
|
122
|
+
throw new CliError(1, `"${site}" is not a web address.`);
|
|
123
|
+
}
|
|
124
|
+
/** The site, with any path, and where it answers comment requests: `<origin><path>`, which keys the saved token. */
|
|
125
|
+
function locate(site, path = DEFAULT_PATH) {
|
|
126
|
+
const url = siteUrl(site);
|
|
127
|
+
const route = `/${path.replace(/^\/+|\/+$/g, '')}`;
|
|
128
|
+
return { site: `${url.origin}${url.pathname.replace(/\/+$/, '')}`, endpoint: `${url.origin}${route === '/' ? '' : route}` };
|
|
129
|
+
}
|
|
130
|
+
function credentialsFile() {
|
|
131
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'visual-comments', 'agents.json');
|
|
132
|
+
}
|
|
133
|
+
function loadAll() {
|
|
134
|
+
const file = credentialsFile();
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
140
|
+
return {};
|
|
141
|
+
throw new CliError(1, `${file} cannot be read: ${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function saveAll(all) {
|
|
145
|
+
const file = credentialsFile();
|
|
146
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
147
|
+
// Written whole, then moved over the old file, so a crash never leaves half of it. Only you can read it.
|
|
148
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
149
|
+
writeFileSync(temporary, `${JSON.stringify(all, null, '\t')}\n`, { mode: 0o600 });
|
|
150
|
+
renameSync(temporary, file);
|
|
151
|
+
}
|
|
152
|
+
/** The site the command is for, when it is connected. Set for the hint on a rejected token. */
|
|
153
|
+
let current;
|
|
154
|
+
function connected(site) {
|
|
155
|
+
const all = loadAll();
|
|
156
|
+
const endpoints = Object.keys(all);
|
|
157
|
+
const wanted = site ?? process.env.VISUAL_COMMENTS_SITE;
|
|
158
|
+
let matches = endpoints;
|
|
159
|
+
if (wanted) {
|
|
160
|
+
const url = siteUrl(wanted);
|
|
161
|
+
const exact = `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
|
162
|
+
matches = endpoints.filter((endpoint) => endpoint === exact);
|
|
163
|
+
if (!matches.length)
|
|
164
|
+
matches = endpoints.filter((endpoint) => new URL(endpoint).origin === url.origin);
|
|
165
|
+
if (!matches.length)
|
|
166
|
+
throw new CliError(2, `Not connected to ${wanted}. Run: visual-comments connect ${wanted}`);
|
|
167
|
+
}
|
|
168
|
+
else if (!matches.length) {
|
|
169
|
+
throw new CliError(2, 'Not connected to any site. Run: visual-comments connect <site>');
|
|
170
|
+
}
|
|
171
|
+
if (matches.length > 1) {
|
|
172
|
+
throw new CliError(1, `Connected to several sites; choose one with --site or VISUAL_COMMENTS_SITE:\n ${matches.join('\n ')}`);
|
|
173
|
+
}
|
|
174
|
+
const endpoint = matches[0];
|
|
175
|
+
current = { endpoint, site: new URL(endpoint).origin, saved: all[endpoint] };
|
|
176
|
+
return current;
|
|
177
|
+
}
|
|
178
|
+
// Requests
|
|
179
|
+
async function call(endpoint, route, init = {}) {
|
|
180
|
+
const headers = { accept: 'application/json' };
|
|
181
|
+
if (init.token)
|
|
182
|
+
headers.authorization = `Bearer ${init.token}`;
|
|
183
|
+
if (init.body !== undefined)
|
|
184
|
+
headers['content-type'] = 'application/json';
|
|
185
|
+
let response;
|
|
186
|
+
let text;
|
|
187
|
+
try {
|
|
188
|
+
response = await fetch(`${endpoint}${route}`, {
|
|
189
|
+
method: init.method ?? 'GET',
|
|
190
|
+
headers,
|
|
191
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
192
|
+
signal: AbortSignal.timeout(init.timeout ?? REQUEST_MS)
|
|
193
|
+
});
|
|
194
|
+
text = await response.text();
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
throw new HttpError(0, `${endpoint} cannot be reached (${reason(error)}).`);
|
|
198
|
+
}
|
|
199
|
+
let data;
|
|
200
|
+
try {
|
|
201
|
+
data = text ? JSON.parse(text) : undefined;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// HTML from the site itself, e.g. a sign-in page: the comments route is elsewhere.
|
|
205
|
+
const message = `${endpoint}${route} did not answer like a comments route (status ${response.status}). Is --path right?`;
|
|
206
|
+
throw new HttpError(response.ok ? 404 : response.status, message);
|
|
207
|
+
}
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
const error = data && typeof data === 'object' && 'error' in data ? data.error : undefined;
|
|
210
|
+
throw new HttpError(response.status, typeof error === 'string' ? error : `The site answered ${response.status}.`);
|
|
211
|
+
}
|
|
212
|
+
return { status: response.status, data: data };
|
|
213
|
+
}
|
|
214
|
+
function reason(error) {
|
|
215
|
+
if (error instanceof Error && error.name === 'TimeoutError')
|
|
216
|
+
return 'no answer in time';
|
|
217
|
+
if (!(error instanceof Error))
|
|
218
|
+
return String(error);
|
|
219
|
+
// fetch fails with "fetch failed"; the cause says why, e.g. ECONNREFUSED.
|
|
220
|
+
const cause = error.cause;
|
|
221
|
+
if (cause instanceof Error)
|
|
222
|
+
return 'code' in cause && typeof cause.code === 'string' ? cause.code : cause.message;
|
|
223
|
+
return error.message;
|
|
224
|
+
}
|
|
225
|
+
/** Worth trying again: the site could not be reached, or had a passing problem. */
|
|
226
|
+
function transient(error) {
|
|
227
|
+
return error instanceof HttpError && (error.status === 0 || error.status === 408 || error.status === 429 || error.status >= 500);
|
|
228
|
+
}
|
|
229
|
+
function backoff(previous) {
|
|
230
|
+
return previous ? Math.min(previous * 2, MAX_BACKOFF_MS) : 1000;
|
|
231
|
+
}
|
|
232
|
+
// Commands
|
|
233
|
+
async function connect(args) {
|
|
234
|
+
const { values, positionals } = parse('connect', args, { name: { type: 'string' }, path: { type: 'string' } });
|
|
235
|
+
if (values.help)
|
|
236
|
+
return help(HELP.connect);
|
|
237
|
+
if (positionals.length !== 1)
|
|
238
|
+
throw new CliError(1, HELP.connect);
|
|
239
|
+
const { site, endpoint } = locate(positionals[0], values.path);
|
|
240
|
+
const name = (values.name ?? 'Agent').trim();
|
|
241
|
+
if (!name || name.length > 60)
|
|
242
|
+
throw new CliError(1, 'The name must be 1 to 60 characters.');
|
|
243
|
+
// Joining again would add a second agent to the space; a saved token that still works is enough.
|
|
244
|
+
const existing = loadAll()[endpoint];
|
|
245
|
+
if (existing) {
|
|
246
|
+
try {
|
|
247
|
+
const { data } = await call(endpoint, '/agents', { token: existing.token });
|
|
248
|
+
const agent = data.agents.find((agent) => agent.id === existing.agent.id);
|
|
249
|
+
if (agent) {
|
|
250
|
+
log(`Already connected to ${site} as ${agent.name}. To join again, run visual-comments disconnect first.`);
|
|
251
|
+
return print({ site, agent });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (!(error instanceof HttpError && error.status === 401))
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const { data: request } = await call(endpoint, '/agents/requests', { method: 'POST', body: { name } });
|
|
260
|
+
const approval = `${site}/#vc-connect=${encodeURIComponent(request.id)}`;
|
|
261
|
+
log(`To let ${name} join, open\n\n ${approval}\n\ncheck that it shows the code ${request.code}, and click Allow. Waiting…`);
|
|
262
|
+
openBrowser(approval);
|
|
263
|
+
// The server answers 404 once the request expires; this only ends retries against a site that stays down.
|
|
264
|
+
const deadline = Date.now() + Math.max(request.expiresAt - Date.now(), 0) + 60_000;
|
|
265
|
+
let delay = 0;
|
|
266
|
+
while (Date.now() < deadline) {
|
|
267
|
+
try {
|
|
268
|
+
const { data } = await call(endpoint, `/agents/requests/${encodeURIComponent(request.id)}/token`, {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
body: { secret: request.secret },
|
|
271
|
+
timeout: LONG_POLL_MS
|
|
272
|
+
});
|
|
273
|
+
delay = 0;
|
|
274
|
+
if ('token' in data) {
|
|
275
|
+
saveAll({ ...loadAll(), [endpoint]: { token: data.token, agent: data.agent } });
|
|
276
|
+
log(`Connected to ${site} as ${data.agent.name}, added by ${data.agent.addedBy.name}.`);
|
|
277
|
+
return print({ site, agent: data.agent });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (error instanceof HttpError && error.status === 404)
|
|
282
|
+
throw new CliError(1, 'The request was denied, or it expired. Run connect again.');
|
|
283
|
+
if (!transient(error))
|
|
284
|
+
throw error;
|
|
285
|
+
delay = backoff(delay);
|
|
286
|
+
log(`${error.message} Trying again in ${delay / 1000} s.`);
|
|
287
|
+
await sleep(delay);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
throw new CliError(1, 'The request expired. Run connect again.');
|
|
291
|
+
}
|
|
292
|
+
/** Best effort: the address is printed anyway. */
|
|
293
|
+
function openBrowser(url) {
|
|
294
|
+
const [command, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
295
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
|
|
296
|
+
: ['xdg-open', [url]];
|
|
297
|
+
try {
|
|
298
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsVerbatimArguments: true });
|
|
299
|
+
child.on('error', () => { });
|
|
300
|
+
child.unref();
|
|
301
|
+
}
|
|
302
|
+
catch { /* no browser here */ }
|
|
303
|
+
}
|
|
304
|
+
async function next(args) {
|
|
305
|
+
const { values } = parse('next', args, { site: { type: 'string' } });
|
|
306
|
+
if (values.help)
|
|
307
|
+
return help(HELP.next);
|
|
308
|
+
const { endpoint, site, saved } = connected(values.site);
|
|
309
|
+
// Each request is short; the loop is what waits, so no proxy times out a request held for hours.
|
|
310
|
+
let delay = 0;
|
|
311
|
+
for (;;) {
|
|
312
|
+
let inbox;
|
|
313
|
+
try {
|
|
314
|
+
({ data: inbox } = await call(endpoint, '/agent/next?wait=50', { token: saved.token, timeout: LONG_POLL_MS }));
|
|
315
|
+
delay = 0;
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
if (!transient(error))
|
|
319
|
+
throw error;
|
|
320
|
+
delay = backoff(delay);
|
|
321
|
+
log(`${error.message} Trying again in ${delay / 1000} s.`);
|
|
322
|
+
await sleep(delay);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (!inbox.threads.length)
|
|
326
|
+
continue;
|
|
327
|
+
// The threads are marked read now, so from here nothing may fail: an image that will not download keeps no path.
|
|
328
|
+
await downloadImages(endpoint, saved.token, inbox.threads);
|
|
329
|
+
return print({ site, threads: inbox.threads, participants: inbox.participants });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function downloadImages(endpoint, token, threads) {
|
|
333
|
+
const host = new URL(endpoint).host;
|
|
334
|
+
const folder = join(tmpdir(), 'visual-comments', process.platform === 'win32' ? host.replace(/:/g, '_') : host);
|
|
335
|
+
mkdirSync(folder, { recursive: true });
|
|
336
|
+
for (const comment of threads.flatMap((thread) => thread.comments)) {
|
|
337
|
+
for (const image of comment.images) {
|
|
338
|
+
const name = `${image.id}-${image.key}`;
|
|
339
|
+
// An image's address never changes, so one downloaded before is the same image.
|
|
340
|
+
const known = Object.values(IMAGE_EXTENSIONS).map((extension) => join(folder, `${name}.${extension}`)).find((path) => existsSync(path));
|
|
341
|
+
if (known) {
|
|
342
|
+
image.path = known;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
image.path = await downloadImage(`${endpoint}/images/${image.id}/${encodeURIComponent(image.key)}`, token, join(folder, name));
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
log(`Image ${image.id} could not be downloaded: ${error.message}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function downloadImage(url, token, base) {
|
|
355
|
+
for (let attempt = 1;; attempt++) {
|
|
356
|
+
try {
|
|
357
|
+
const response = await fetch(url, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(REQUEST_MS) });
|
|
358
|
+
if (!response.ok)
|
|
359
|
+
throw new HttpError(response.status, `the site answered ${response.status}`);
|
|
360
|
+
const type = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() ?? '';
|
|
361
|
+
const path = `${base}.${IMAGE_EXTENSIONS[type] ?? 'bin'}`;
|
|
362
|
+
writeFileSync(path, new Uint8Array(await response.arrayBuffer()));
|
|
363
|
+
return path;
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
const failure = error instanceof HttpError ? error : new HttpError(0, reason(error));
|
|
367
|
+
if (attempt >= 3 || !transient(failure))
|
|
368
|
+
throw failure;
|
|
369
|
+
await sleep(1000 * attempt);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function reply(args) {
|
|
374
|
+
const { values, positionals } = parse('reply', args, { body: { type: 'string' }, site: { type: 'string' } });
|
|
375
|
+
if (values.help)
|
|
376
|
+
return help(HELP.reply);
|
|
377
|
+
if (positionals.length !== 1 || !/^\d+$/.test(positionals[0].replace(/^#/, '')))
|
|
378
|
+
throw new CliError(1, HELP.reply);
|
|
379
|
+
const number = Number(positionals[0].replace(/^#/, ''));
|
|
380
|
+
const { endpoint, saved } = connected(values.site);
|
|
381
|
+
const body = (values.body ?? (process.stdin.isTTY ? '' : await readStdin())).trim();
|
|
382
|
+
if (!body)
|
|
383
|
+
throw new CliError(1, 'The reply is empty. Pass --body, or the text on stdin.');
|
|
384
|
+
const { data } = await call(endpoint, `/threads/${number}/comments`, { method: 'POST', token: saved.token, body: { body } });
|
|
385
|
+
print({ thread: { id: data.thread.id, url: data.thread.url, comment: data.thread.comments.at(-1)?.id } });
|
|
386
|
+
}
|
|
387
|
+
async function readStdin() {
|
|
388
|
+
const chunks = [];
|
|
389
|
+
for await (const chunk of process.stdin)
|
|
390
|
+
chunks.push(chunk);
|
|
391
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
392
|
+
}
|
|
393
|
+
async function presence(args) {
|
|
394
|
+
const { values, positionals } = parse('presence', args, {
|
|
395
|
+
key: { type: 'string' },
|
|
396
|
+
thread: { type: 'string' },
|
|
397
|
+
surface: { type: 'string' },
|
|
398
|
+
page: { type: 'string' },
|
|
399
|
+
note: { type: 'string' },
|
|
400
|
+
site: { type: 'string' }
|
|
401
|
+
});
|
|
402
|
+
if (values.help)
|
|
403
|
+
return help(HELP.presence);
|
|
404
|
+
const state = positionals[0];
|
|
405
|
+
if (positionals.length !== 1 || (state !== 'viewing' && state !== 'working' && state !== 'gone'))
|
|
406
|
+
throw new CliError(1, HELP.presence);
|
|
407
|
+
const update = { state };
|
|
408
|
+
if (values.key !== undefined)
|
|
409
|
+
update.key = values.key;
|
|
410
|
+
if (values.thread !== undefined) {
|
|
411
|
+
const thread = Number(values.thread.replace(/^#/, ''));
|
|
412
|
+
if (!Number.isInteger(thread) || thread < 1)
|
|
413
|
+
throw new CliError(1, '--thread must be a thread number.');
|
|
414
|
+
update.thread = thread;
|
|
415
|
+
}
|
|
416
|
+
if (values.surface !== undefined)
|
|
417
|
+
update.surface = values.surface;
|
|
418
|
+
if (values.page !== undefined)
|
|
419
|
+
update.page = values.page;
|
|
420
|
+
if (values.note !== undefined)
|
|
421
|
+
update.note = values.note;
|
|
422
|
+
const { endpoint, saved } = connected(values.site);
|
|
423
|
+
await call(endpoint, '/presence', { method: 'POST', token: saved.token, body: update });
|
|
424
|
+
}
|
|
425
|
+
async function agents(args) {
|
|
426
|
+
const { values } = parse('agents', args, { site: { type: 'string' } });
|
|
427
|
+
if (values.help)
|
|
428
|
+
return help(HELP.agents);
|
|
429
|
+
const { endpoint, site, saved } = connected(values.site);
|
|
430
|
+
const { data } = await call(endpoint, '/agents', { token: saved.token });
|
|
431
|
+
print({ site, me: saved.agent.id, agents: data.agents });
|
|
432
|
+
}
|
|
433
|
+
async function disconnect(args) {
|
|
434
|
+
const { values } = parse('disconnect', args, { site: { type: 'string' } });
|
|
435
|
+
if (values.help)
|
|
436
|
+
return help(HELP.disconnect);
|
|
437
|
+
const { endpoint, site, saved } = connected(values.site);
|
|
438
|
+
try {
|
|
439
|
+
await call(endpoint, `/agents/${encodeURIComponent(saved.agent.id)}`, { method: 'DELETE', token: saved.token });
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
// Already removed by a person, or expired: forgetting the token is all that is left.
|
|
443
|
+
if (!(error instanceof HttpError && error.status === 401))
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
const all = loadAll();
|
|
447
|
+
delete all[endpoint];
|
|
448
|
+
saveAll(all);
|
|
449
|
+
log(`Disconnected from ${site}.`);
|
|
450
|
+
print({ deleted: saved.agent.id });
|
|
451
|
+
}
|
|
452
|
+
const COMMANDS = { connect, next, reply, presence, agents, disconnect };
|
|
453
|
+
async function main(argv) {
|
|
454
|
+
const [command, ...args] = argv;
|
|
455
|
+
if (!command || command === '-h' || command === '--help' || command === 'help') {
|
|
456
|
+
help(HELP.main);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const run = COMMANDS[command];
|
|
460
|
+
if (!run)
|
|
461
|
+
throw new CliError(1, `Unknown command "${command}".\n\n${HELP.main}`);
|
|
462
|
+
await run(args);
|
|
463
|
+
}
|
|
464
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
465
|
+
if (error instanceof CliError) {
|
|
466
|
+
log(error.message);
|
|
467
|
+
process.exitCode = error.code;
|
|
468
|
+
}
|
|
469
|
+
else if (error instanceof HttpError && error.status === 401 && current) {
|
|
470
|
+
log(`${error.message} Run: visual-comments connect ${current.site}`);
|
|
471
|
+
process.exitCode = 2;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
log(error instanceof Error ? error.message : String(error));
|
|
475
|
+
process.exitCode = 1;
|
|
476
|
+
}
|
|
477
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type CommentTarget } from './target.js';
|
|
2
|
+
import { type Surface } from './surfaces.js';
|
|
3
|
+
import type { Box } from './target.js';
|
|
4
|
+
/** Where the pointer is aiming in comment mode. */
|
|
5
|
+
export interface Aim {
|
|
6
|
+
surface: string;
|
|
7
|
+
/** Where a pin would go, in surface pixels: the pointer, moved onto the target if it is pulled to one. */
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
/** The pointer itself, in surface pixels. */
|
|
11
|
+
point: {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
};
|
|
15
|
+
/** In the surface's document. */
|
|
16
|
+
target?: CommentTarget;
|
|
17
|
+
/** The target's box in surface pixels when it was aimed at. */
|
|
18
|
+
targetBox?: Box;
|
|
19
|
+
}
|
|
20
|
+
/** Where a pin goes for a pointer at a client point. With `free`, or without a document, it stays at the pointer. */
|
|
21
|
+
export declare function aimAt(surface: Surface, x: number, y: number, free: boolean): Aim;
|
package/dist/core/aim.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { clamp, MAGNET, targetAt } from './target.js';
|
|
2
|
+
import { clientToDoc, docBoxToSurface, fromClient } from './surfaces.js';
|
|
3
|
+
/** Where a pin goes for a pointer at a client point. With `free`, or without a document, it stays at the pointer. */
|
|
4
|
+
export function aimAt(surface, x, y, free) {
|
|
5
|
+
const point = fromClient(surface, x, y);
|
|
6
|
+
const plain = { surface: surface.id, x: point.x, y: point.y, point };
|
|
7
|
+
if (free || !surface.doc)
|
|
8
|
+
return plain;
|
|
9
|
+
const d = clientToDoc(surface, x, y);
|
|
10
|
+
const target = targetAt(surface.doc, d.x, d.y, MAGNET / surface.docScale, surface.search);
|
|
11
|
+
if (!target)
|
|
12
|
+
return plain;
|
|
13
|
+
const box = docBoxToSurface(surface, target.box);
|
|
14
|
+
return {
|
|
15
|
+
...plain,
|
|
16
|
+
x: clamp(point.x, box.x + 1, box.x + box.width - 1),
|
|
17
|
+
y: clamp(point.y, box.y + 1, box.y + box.height - 1),
|
|
18
|
+
target,
|
|
19
|
+
targetBox: box
|
|
20
|
+
};
|
|
21
|
+
}
|
package/dist/core/api.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The comments on the site's own server, which passes requests on to the
|
|
2
|
+
// comment server with the space's secret and the person signed in on the site.
|
|
3
|
+
export class Api {
|
|
4
|
+
endpoint;
|
|
5
|
+
constructor(endpoint) {
|
|
6
|
+
this.endpoint = endpoint.replace(/\/$/, '');
|
|
7
|
+
}
|
|
8
|
+
fetch(path, init = {}) {
|
|
9
|
+
return fetch(`${this.endpoint}${path}`, { ...init, credentials: 'same-origin', cache: 'no-store' });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** A rounded box in pixels. */
|
|
2
|
+
export interface RoundedBox {
|
|
3
|
+
left: number;
|
|
4
|
+
top: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
radius: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The bridge from a dot of radius `r` at `x`, `y` to the nearest edge of a
|
|
11
|
+
* box, as between two metaballs about to part: wide where it meets each,
|
|
12
|
+
* pinched between, and thinner the farther it stretches. Undefined when the
|
|
13
|
+
* dot touches or is inside the box, or is beyond `reach` from it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function neck(x: number, y: number, r: number, box: RoundedBox, reach: number): string | undefined;
|