symbiot 0.41.7 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -11
- package/agents.mjs +108 -7
- package/headless.mjs +80 -17
- package/index.mjs +9 -5
- package/links.mjs +149 -0
- package/package.json +3 -2
- package/screens.mjs +28 -5
- package/server.mjs +14 -2
- package/tasks.mjs +1 -0
- package/ui.mjs +87 -15
- package/watch.mjs +66 -8
- package/writeups.mjs +19 -7
package/links.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// symbiot — Links: one button per standard work site (Gmail, Outlook, GitHub,
|
|
2
|
+
// Slack, Teams, Jira…), so linking your work is a click, not a list of sites to
|
|
3
|
+
// type in one by one. Linking a site does three things at once:
|
|
4
|
+
// - opens it in Symbiot's browser (headless.mjs signIn), where you sign in the
|
|
5
|
+
// usual way, with the site's own login, SSO and 2FA (Symbiot never sees a password);
|
|
6
|
+
// - trusts its domains (Screens' Press and Type go ahead there without asking);
|
|
7
|
+
// - watches its inbox or notifications page (watch.mjs), so what arrives there
|
|
8
|
+
// reaches the Dashboard, Standup and Week: your week, from all of your work.
|
|
9
|
+
// Whether a link works is read from its watch: signed in, signed out, or still
|
|
10
|
+
// waiting for you to sign in.
|
|
11
|
+
//
|
|
12
|
+
// For a whole company, links.json (in Symbiot's config folder, or the file
|
|
13
|
+
// SYMBIOT_LINKS names) adds the company's own sites and hides the ones it
|
|
14
|
+
// doesn't use, so everyone gets the same buttons:
|
|
15
|
+
// { "links": [{ "id": "jira", "name": "Jira", "group": "Work", "url": "https://acme.atlassian.net/jira/your-work", "hosts": ["acme.atlassian.net"] }],
|
|
16
|
+
// "hide": ["whatsapp"] }
|
|
17
|
+
// An entry with a built-in's id replaces it. Linking stays in the app: like
|
|
18
|
+
// trusted sites, there's no command an agent could call to link a site itself.
|
|
19
|
+
//
|
|
20
|
+
// What's linked is config.linked: { <id>: { at, watch?, hosts: [trusted by the link] } }.
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
|
|
24
|
+
import { siteUrl, signIn, readPage, trustedSites, trustSite, untrustSite } from "./headless.mjs";
|
|
25
|
+
import { watchState, addWatch, removeWatch, checkWatch, GITHUB_INBOX } from "./watch.mjs";
|
|
26
|
+
|
|
27
|
+
// The standard sites. url is where you sign in and, with watch, the page that's
|
|
28
|
+
// read for what's new; hosts are trusted along with it.
|
|
29
|
+
const CATALOG = [
|
|
30
|
+
{ id: "gmail", name: "Gmail", group: "Mail", url: "https://mail.google.com/mail/u/0/#inbox", hosts: ["mail.google.com"], watch: true },
|
|
31
|
+
{ id: "outlook", name: "Outlook", group: "Mail", url: "https://outlook.office.com/mail/", hosts: ["outlook.office.com", "outlook.office365.com", "outlook.live.com"], watch: true },
|
|
32
|
+
{ id: "gcal", name: "Google Calendar", group: "Calendar", url: "https://calendar.google.com/calendar/r/agenda", hosts: ["calendar.google.com"], watch: true },
|
|
33
|
+
{ id: "outlookcal", name: "Outlook Calendar", group: "Calendar", url: "https://outlook.office.com/calendar/view/agenda", hosts: ["outlook.office.com", "outlook.office365.com"], watch: true },
|
|
34
|
+
{ id: "github", name: "GitHub", group: "Code", url: GITHUB_INBOX, hosts: ["github.com"], watch: true },
|
|
35
|
+
{ id: "gitlab", name: "GitLab", group: "Code", url: "https://gitlab.com/dashboard/todos", hosts: ["gitlab.com"], watch: true },
|
|
36
|
+
{ id: "slack", name: "Slack", group: "Chat", url: "https://app.slack.com/client", hosts: ["app.slack.com"], watch: true },
|
|
37
|
+
{ id: "teams", name: "Microsoft Teams", group: "Chat", url: "https://teams.microsoft.com/", hosts: ["teams.microsoft.com"], watch: true },
|
|
38
|
+
{ id: "whatsapp", name: "WhatsApp", group: "Chat", url: "https://web.whatsapp.com/", hosts: ["web.whatsapp.com"], watch: true },
|
|
39
|
+
{ id: "jira", name: "Jira & Confluence", group: "Work", url: "https://home.atlassian.com/", hosts: ["atlassian.net", "home.atlassian.com"], watch: true },
|
|
40
|
+
{ id: "linear", name: "Linear", group: "Work", url: "https://linear.app/", hosts: ["linear.app"], watch: true },
|
|
41
|
+
{ id: "asana", name: "Asana", group: "Work", url: "https://app.asana.com/", hosts: ["app.asana.com"], watch: true },
|
|
42
|
+
{ id: "trello", name: "Trello", group: "Work", url: "https://trello.com/", hosts: ["trello.com"] },
|
|
43
|
+
{ id: "gdrive", name: "Google Drive", group: "Docs", url: "https://drive.google.com/drive/recent", hosts: ["drive.google.com", "docs.google.com"] },
|
|
44
|
+
{ id: "notion", name: "Notion", group: "Docs", url: "https://www.notion.so/", hosts: ["notion.so"] },
|
|
45
|
+
{ id: "hubspot", name: "HubSpot", group: "Sales", url: "https://app.hubspot.com/", hosts: ["app.hubspot.com"] },
|
|
46
|
+
{ id: "salesforce", name: "Salesforce", group: "Sales", url: "https://login.salesforce.com/", hosts: ["salesforce.com", "lightning.force.com"] },
|
|
47
|
+
];
|
|
48
|
+
const GROUPS = ["Mail", "Calendar", "Code", "Chat", "Work", "Docs", "Sales"];
|
|
49
|
+
|
|
50
|
+
const hostOf = (url) => { try { return new URL(url).hostname.replace(/^www\./, "").toLowerCase(); } catch { return ""; } };
|
|
51
|
+
const linksFile = () => process.env.SYMBIOT_LINKS || join(CONFIG_DIR, "links.json");
|
|
52
|
+
// One entry from links.json, tidied, or null if it can't be linked.
|
|
53
|
+
function cleanEntry(e) {
|
|
54
|
+
if (!e || typeof e !== "object") return null;
|
|
55
|
+
const url = siteUrl(e.url), id = String(e.id || hostOf(url)).trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 40);
|
|
56
|
+
if (!url || !id) return null;
|
|
57
|
+
const hosts = [...new Set([hostOf(url), ...(Array.isArray(e.hosts) ? e.hosts : [])].map((h) => hostOf(siteUrl(h))).filter(Boolean))];
|
|
58
|
+
return { id, name: String(e.name || hostOf(url)).trim().slice(0, 60), group: String(e.group || "Company").trim().slice(0, 30), url, hosts, ...(e.watch ? { watch: true } : {}), company: true };
|
|
59
|
+
}
|
|
60
|
+
// The buttons everyone gets: the built-ins, then the company file's (an entry
|
|
61
|
+
// with a built-in's id replaces it; hide drops ids). A broken file is ignored,
|
|
62
|
+
// and said so.
|
|
63
|
+
function catalog() {
|
|
64
|
+
let file = null, error = "";
|
|
65
|
+
try { file = JSON.parse(readFileSync(linksFile(), "utf8")); } catch (e) { if (e && e.code !== "ENOENT") error = `${linksFile()} isn't valid JSON, so it was skipped.`; }
|
|
66
|
+
const extra = ((file && Array.isArray(file.links) && file.links) || []).map(cleanEntry).filter(Boolean);
|
|
67
|
+
const hide = new Set(((file && Array.isArray(file.hide) && file.hide) || []).map((x) => String(x).toLowerCase()));
|
|
68
|
+
const byId = new Map(); for (const e of [...CATALOG, ...extra]) byId.set(e.id, e);
|
|
69
|
+
const list = [...byId.values()].filter((e) => !hide.has(e.id));
|
|
70
|
+
return { list, error, file: file ? linksFile() : "" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function linked() { const l = loadConfig().linked; return l && typeof l === "object" && !Array.isArray(l) ? l : {}; }
|
|
74
|
+
function saveLinked(l) { const cfg = loadConfig(); if (Object.keys(l).length) cfg.linked = l; else delete cfg.linked; return saveConfig(cfg); }
|
|
75
|
+
|
|
76
|
+
// Where a link stands, from its watch. state: "off" (not linked), "signin" (linked,
|
|
77
|
+
// not read signed in yet: sign in in the window, then close it), "ok" (read
|
|
78
|
+
// signed in), "signedout" or "error".
|
|
79
|
+
function stateOf(e, l, watches) {
|
|
80
|
+
if (!l) return { state: "off" };
|
|
81
|
+
const w = l.watch && watches.find((x) => x.id === l.watch);
|
|
82
|
+
if (!e.watch) return { state: l.ok ? "ok" : "signin", ...(l.checked ? { checked: l.checked } : {}) };
|
|
83
|
+
if (!w) return { state: "error", note: "Its watch was removed. Link it again." };
|
|
84
|
+
// signed out only once it has been read signed in: a read before you've
|
|
85
|
+
// finished signing in in the window is still "sign in"
|
|
86
|
+
if (w.error && /^Signed out/.test(w.error)) return w.checked ? { state: "signedout", note: "Signed out: click it to sign in again." } : { state: "signin" };
|
|
87
|
+
if (w.checked) return { state: "ok", checked: w.checked, ...(w.error ? { note: w.error } : {}) };
|
|
88
|
+
return { state: "signin", ...(w.error && !/in use|busy|already running/i.test(w.error) ? { note: w.error } : {}) };
|
|
89
|
+
}
|
|
90
|
+
function linksState() {
|
|
91
|
+
const c = catalog(), l = linked(), watches = watchState().watches;
|
|
92
|
+
const items = c.list.map((e) => ({ id: e.id, name: e.name, group: e.group, url: e.url, ...(e.company ? { company: true } : {}), ...stateOf(e, l[e.id], watches) }));
|
|
93
|
+
const groups = [...new Set([...GROUPS, ...items.map((x) => x.group)])].filter((g) => items.some((x) => x.group === g));
|
|
94
|
+
return { items, groups, linked: items.filter((x) => x.state !== "off").length, ...(c.file ? { file: c.file } : {}), ...(c.error ? { error: c.error } : {}) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Link one: trust its hosts, watch its page, open it to sign in. Clicking a link
|
|
98
|
+
// that's already there just opens it again to sign in (signed out, or a second
|
|
99
|
+
// account). Gives { ok, url, item } or { error }.
|
|
100
|
+
async function linkSite(id, { open = signIn } = {}) {
|
|
101
|
+
const e = catalog().list.find((x) => x.id === id);
|
|
102
|
+
if (!e) return { error: `No link called ${id}.` };
|
|
103
|
+
const l = linked(), had = l[id], before = new Set(trustedSites()), added = [];
|
|
104
|
+
for (const h of e.hosts) if (!before.has(h)) { const r = trustSite(h); if (r && r.ok) added.push(h); }
|
|
105
|
+
let watch = had && had.watch;
|
|
106
|
+
if (e.watch && !(watch && watchState().watches.some((x) => x.id === watch))) {
|
|
107
|
+
const w = addWatch({ site: e.url, name: e.name, every: 15 });
|
|
108
|
+
if (w.error) { for (const h of added) untrustSite(h); return { error: w.error }; }
|
|
109
|
+
watch = w.id;
|
|
110
|
+
}
|
|
111
|
+
l[id] = { at: (had && had.at) || Date.now(), ...(watch ? { watch } : {}), hosts: [...new Set([...((had && had.hosts) || []), ...added])] };
|
|
112
|
+
saveLinked(l);
|
|
113
|
+
const r = await open(e.url);
|
|
114
|
+
return r.error ? { error: r.error, item: linksState().items.find((x) => x.id === id) } : { ok: true, url: r.url, item: linksState().items.find((x) => x.id === id) };
|
|
115
|
+
}
|
|
116
|
+
// Read it now: its watch, or for a site that isn't watched, its page, to see
|
|
117
|
+
// that it's signed in. Gives the item as it stands after.
|
|
118
|
+
async function checkLink(id, { check = checkWatch, read = readPage } = {}) {
|
|
119
|
+
const e = catalog().list.find((x) => x.id === id), l = linked();
|
|
120
|
+
if (!e || !l[id]) return { error: `${e ? e.name : id} isn't linked.` };
|
|
121
|
+
if (l[id].watch) { const r = await check(l[id].watch); if (r && r.busy) return { busy: true, item: linksState().items.find((x) => x.id === id) }; }
|
|
122
|
+
else {
|
|
123
|
+
const p = await read(e.url);
|
|
124
|
+
if (p && p.busy) return { busy: true, item: linksState().items.find((x) => x.id === id) };
|
|
125
|
+
const l2 = linked(); if (!l2[id]) return { error: `${e.name} isn't linked.` };
|
|
126
|
+
l2[id] = { ...l2[id], ok: !!(p && !p.error && !p.login), checked: Date.now() }; saveLinked(l2);
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, item: linksState().items.find((x) => x.id === id) };
|
|
129
|
+
}
|
|
130
|
+
// Unlink: stop watching it, and untrust the hosts this link trusted (never ones
|
|
131
|
+
// you trusted yourself). A host another link still uses (Outlook and Outlook
|
|
132
|
+
// Calendar share one) passes to that link instead, so it goes when the last
|
|
133
|
+
// link using it does. You stay signed in to the site in Symbiot's browser until
|
|
134
|
+
// you sign out there.
|
|
135
|
+
function unlinkSite(id) {
|
|
136
|
+
const l = linked(), had = l[id]; if (!had) return { error: `${id} isn't linked.` };
|
|
137
|
+
delete l[id];
|
|
138
|
+
if (had.watch) removeWatch(had.watch);
|
|
139
|
+
const list = catalog().list;
|
|
140
|
+
for (const h of had.hosts || []) {
|
|
141
|
+
const heir = Object.keys(l).find((k) => { const e = list.find((x) => x.id === k); return e && e.hosts.includes(h); });
|
|
142
|
+
if (heir) l[heir] = { ...l[heir], hosts: [...new Set([...(l[heir].hosts || []), h])] };
|
|
143
|
+
else untrustSite(h);
|
|
144
|
+
}
|
|
145
|
+
saveLinked(l);
|
|
146
|
+
return { ok: true, ...linksState() };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export { CATALOG, catalog, cleanEntry, linksState, linkSite, checkLink, unlinkSite };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"screens.mjs",
|
|
22
22
|
"headless.mjs",
|
|
23
23
|
"watch.mjs",
|
|
24
|
+
"links.mjs",
|
|
24
25
|
"phone.mjs",
|
|
25
26
|
"desktop.mjs",
|
|
26
27
|
"README.md",
|
|
@@ -57,7 +58,7 @@
|
|
|
57
58
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
58
59
|
},
|
|
59
60
|
"scripts": {
|
|
60
|
-
"test": "node test/load.mjs && node test/fixtures.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
61
|
+
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
61
62
|
"lint": "eslint ."
|
|
62
63
|
},
|
|
63
64
|
"devDependencies": {
|
package/screens.mjs
CHANGED
|
@@ -112,6 +112,28 @@ function stitchPng(pieces, h) {
|
|
|
112
112
|
for (const { img, y } of imgs) { const to = Math.min(h, y + img.h); if (to > y) img.px.copy(px, y * first.stride, 0, (to - y) * img.stride); }
|
|
113
113
|
return pngCrop({ ...first, h, px }, { x: 0, y: 0, w: first.w, h });
|
|
114
114
|
}
|
|
115
|
+
// One PNG of a window whose list scrolls inside it (Gmail's mail), as if the list
|
|
116
|
+
// were opened out `more` pixels longer: the first shot down to the list's bottom,
|
|
117
|
+
// the list's box ({ x, y, w, h } in the window) filled from each shot ({ png, y }:
|
|
118
|
+
// the window with the list scrolled y down) at its place, the later one's kept
|
|
119
|
+
// where they overlap, and what's below the list in the last shot moved down by
|
|
120
|
+
// `more`. Beside the opened-out list, each column carries on in its colour at the
|
|
121
|
+
// list's bottom edge. null when a shot can't be read, or they differ.
|
|
122
|
+
function stitchListPng(pieces, box, more) {
|
|
123
|
+
const imgs = pieces.map((p) => ({ img: pngDecode(p.png), y: Math.max(0, Math.round(p.y) || 0) })), first = imgs.length && imgs[0].img;
|
|
124
|
+
if (!first || !box || imgs.some(({ img }) => !img || img.w !== first.w || img.h !== first.h || img.bpp !== first.bpp || img.ctype !== first.ctype || img.depth !== first.depth)) return null;
|
|
125
|
+
more = Math.max(0, Math.round(more) || 0);
|
|
126
|
+
const H = first.h, h = H + more, st = first.stride, bpp = first.bpp, end = Math.min(H, box.y + box.h), last = imgs[imgs.length - 1].img;
|
|
127
|
+
if (box.x < 0 || box.y < 0 || box.w <= 0 || box.x + box.w > first.w || end <= box.y) return null;
|
|
128
|
+
const px = Buffer.alloc(st * h);
|
|
129
|
+
first.px.copy(px, 0, 0, end * st);
|
|
130
|
+
for (let y = end; y < end + more; y++) first.px.copy(px, y * st, (end - 1) * st, end * st);
|
|
131
|
+
last.px.copy(px, (end + more) * st, end * st, H * st);
|
|
132
|
+
for (const { img, y: at } of imgs) {
|
|
133
|
+
for (let r = box.y; r < end && r + at < end + more; r++) img.px.copy(px, (r + at) * st + box.x * bpp, r * st + box.x * bpp, r * st + (box.x + box.w) * bpp);
|
|
134
|
+
}
|
|
135
|
+
return pngCrop({ ...first, h, px }, { x: 0, y: 0, w: first.w, h });
|
|
136
|
+
}
|
|
115
137
|
|
|
116
138
|
// Displays: where each one sits on the desktop, as { name, x, y, w, h } in the
|
|
117
139
|
// units the click tool uses (logical pixels on Wayland, X pixels on X11, physical
|
|
@@ -378,14 +400,15 @@ function setRegions(id, regions) {
|
|
|
378
400
|
}
|
|
379
401
|
// A web page mapped in the hidden browser (headless.mjs): its screenshot, with a
|
|
380
402
|
// region for each button, link and field found on it. `page` is { url, title,
|
|
381
|
-
// scroll?, full? }: scroll is how far down the window is ({ y, max } in
|
|
382
|
-
// the selector of what scrolls when it isn't the page itself), when
|
|
383
|
-
// full is set on a map of the whole page in one tall screenshot
|
|
403
|
+
// scroll?, full?, list? }: scroll is how far down the window is ({ y, max } in
|
|
404
|
+
// pixels, and the selector of what scrolls when it isn't the page itself), when
|
|
405
|
+
// there's more; full is set on a map of the whole page in one tall screenshot, and
|
|
406
|
+
// list on one whose list (its selector) was opened out in it.
|
|
384
407
|
function addPageScreen(name, png, page, regions) {
|
|
385
408
|
const size = pngSize(png); if (!size) return { error: "The page's screenshot isn't a PNG." };
|
|
386
409
|
const id = newId();
|
|
387
410
|
try { mkdirSync(SCREENS_DIR, { recursive: true }); writePrivate(screenFile(id), png); } catch (e) { return { error: String((e && e.message) || e) }; }
|
|
388
|
-
const p = { url: String(page.url || "").slice(0, 2000), title: String(page.title || "").trim().slice(0, 200), ...(page.full ? { full: true } : {}) };
|
|
411
|
+
const p = { url: String(page.url || "").slice(0, 2000), title: String(page.title || "").trim().slice(0, 200), ...(page.full ? { full: true } : {}), ...(page.full && typeof page.list === "string" && page.list.trim() ? { list: page.list.trim().slice(0, 1000) } : {}) };
|
|
389
412
|
const sc = page.scroll || {}, max = Math.round(Number(sc.max) || 0);
|
|
390
413
|
if (max > 0) p.scroll = { y: Math.max(0, Math.min(max, Math.round(Number(sc.y) || 0))), max, ...(typeof sc.selector === "string" && sc.selector.trim() ? { selector: sc.selector.trim().slice(0, 1000) } : {}) };
|
|
391
414
|
return addScreen(id, name, size, "headless", { page: p, regions: cleanRegions(size, regions) });
|
|
@@ -463,4 +486,4 @@ function clickRegion(id, regionId) {
|
|
|
463
486
|
return { error: `No click tool found. Install ${want}.` };
|
|
464
487
|
}
|
|
465
488
|
|
|
466
|
-
export { loadScreens, screenImage, pngSize, pngDecode, splitPng, stitchPng, captureCmds, monitorCmds, parseCosmicRandr, parseWlrRandr, parseKscreen, parseXrandr, parseLines, tidyMonitors, monitorAreas, listMonitors, portalAppId, allowScreenshots, captureScreen, splitScreen, importScreen, setRegions, addPageScreen, renameScreen, removeScreen, blueprint, center, clickCmds, clickRegion };
|
|
489
|
+
export { loadScreens, screenImage, pngSize, pngDecode, splitPng, stitchPng, stitchListPng, captureCmds, monitorCmds, parseCosmicRandr, parseWlrRandr, parseKscreen, parseXrandr, parseLines, tidyMonitors, monitorAreas, listMonitors, portalAppId, allowScreenshots, captureScreen, splitScreen, importScreen, setRegions, addPageScreen, renameScreen, removeScreen, blueprint, center, clickCmds, clickRegion };
|
package/server.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { createServer } from "node:http";
|
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
12
12
|
import { VERSION, LATEST_VERSION, semverGt, checkLatest, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
|
|
13
|
-
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, agentsList } from "./agents.mjs";
|
|
13
|
+
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, agentsList, startWaiting } from "./agents.mjs";
|
|
14
14
|
import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
15
15
|
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
|
|
16
16
|
import { computeDrift } from "./drift.mjs";
|
|
@@ -19,7 +19,8 @@ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailSt
|
|
|
19
19
|
import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
|
|
20
20
|
import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
|
|
21
21
|
import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
|
|
22
|
-
import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, watchBoard } from "./watch.mjs";
|
|
22
|
+
import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
|
|
23
|
+
import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
|
|
23
24
|
import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
|
|
24
25
|
|
|
25
26
|
// The in-app update installs the EXACT newest version (not the `latest` tag, which
|
|
@@ -210,6 +211,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
210
211
|
if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
|
|
211
212
|
if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
|
|
212
213
|
if (u.pathname === "/api/screens/signin" && req.method === "POST") { const b = await readBody(req); return json(res, await signIn(b.site)); }
|
|
214
|
+
// Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
|
|
215
|
+
if (u.pathname === "/api/links") return json(res, linksState());
|
|
216
|
+
if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
|
|
217
|
+
if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
|
|
218
|
+
if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
|
|
213
219
|
// Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
|
|
214
220
|
if (u.pathname === "/api/watch") return json(res, watchState());
|
|
215
221
|
if (u.pathname === "/api/watch/board") return json(res, watchBoard(Math.min(168, Math.max(1, Number(u.searchParams.get("hours")) || 24))));
|
|
@@ -222,6 +228,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
222
228
|
if (u.pathname === "/api/watch/brief" && req.method === "POST") { const b = await readBody(req); return json(res, setBrief(b.on === true)); }
|
|
223
229
|
// Draft a reply: a new email handed to your agent, which leaves a reply in Drafts (never sends)
|
|
224
230
|
if (u.pathname === "/api/watch/draft" && req.method === "POST") { const b = await readBody(req); return json(res, draftReply(String(b.id || ""))); }
|
|
231
|
+
// Open in WhatsApp: a drafted chat reply, in Symbiot's browser window, to read and send
|
|
232
|
+
if (u.pathname === "/api/watch/open-chat" && req.method === "POST") { const b = await readBody(req); return json(res, await openChat(String(b.id || ""))); }
|
|
233
|
+
// a Dashboard card's chat: go over what's new with your AI before a reply is drafted
|
|
234
|
+
if (u.pathname === "/api/watch/chat" && req.method === "POST") { const b = await readBody(req); return json(res, await boardChat(String(b.id || ""), b.question)); }
|
|
235
|
+
if (u.pathname === "/api/watch/chat/clear" && req.method === "POST") { const b = await readBody(req); return json(res, clearBoardChat(String(b.id || ""))); }
|
|
225
236
|
// Watch on your phone (phone.mjs). On the computer: listen on your network
|
|
226
237
|
// for the phone (only on your click), a pairing code, the phones paired.
|
|
227
238
|
// On the phone: pair with the computer, ask it now, forget it.
|
|
@@ -281,6 +292,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
281
292
|
startWeekly(writeup); // the weekly write-up + notification, when switched on in Settings
|
|
282
293
|
startWatches(); // pages you watch (Screens → Watch), read every few minutes
|
|
283
294
|
startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
|
|
295
|
+
setInterval(() => { try { startWaiting(); } catch {} }, 20000).unref(); // a run that waits for your step starts once the file it names changes
|
|
284
296
|
}
|
|
285
297
|
|
|
286
298
|
export { updateCmd, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
|
package/tasks.mjs
CHANGED
|
@@ -438,6 +438,7 @@ function buildTasksMd(name, ctx, list) {
|
|
|
438
438
|
L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
|
|
439
439
|
`- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then an optional line of context, then 2–4 options as \`- \` bullets (put the one you recommend first).`,
|
|
440
440
|
"- Start every option with who acts, because the user reads it as something they'd be agreeing to do: `👤 You:` when they have to do something (a setting, a click, a command), `🤖 Agent:` when picking it is enough and the next run does the work. If it takes both, put their step first: `👤 You: allow npm install in Settings. 🤖 Agent: the next run adds ESLint`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`, `keep it as it is`) gets no tag.",
|
|
441
|
+
"- A `👤 You:` answer holds the next run back until the user's step is done. If their step changes a file, name the file in backticks (`.env`, `~/.termux/termux.properties`): the next run starts once it changes.",
|
|
441
442
|
"- If a step needs a release that isn't out yet, name its version in the question (\"Once 0.41.0 is installed: …\"). Symbiot shows it next to the installed and npm versions, and holds back an answer that starts \"Done\" until that release is out and installed.",
|
|
442
443
|
"- Ideas, options or follow-ups outside these tasks go under `## Suggestions` as `- ` bullets — the user can add them to their tasks in one click.",
|
|
443
444
|
"- An idea for a different project than this one (Symbiot itself, say, the app that sent you this brief) starts with that project's folder name, `- [repo: symbiot] …`, so it goes to that project's tasks instead of this one's.",
|
package/ui.mjs
CHANGED
|
@@ -95,6 +95,10 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
95
95
|
.drift .ev{color:var(--faint);font-family:ui-monospace,Menlo,monospace;font-size:11px}
|
|
96
96
|
.dot-w{width:9px;height:9px;border-radius:50%;background:var(--amber);display:inline-block}
|
|
97
97
|
.dot-c{width:9px;height:9px;border-radius:50%;background:var(--green);display:inline-block}
|
|
98
|
+
.lgroup{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:8px 0 0}.lgroup .fl{min-width:64px;margin-left:0}
|
|
99
|
+
.lnk{display:inline-flex;align-items:center;gap:4px}.lnk .fchip{display:inline-flex;align-items:center;gap:6px}
|
|
100
|
+
.lnk.ok .fchip{border-color:#2a6b52}.lnk.signin .fchip,.lnk.signedout .fchip,.lnk.error .fchip{border-color:var(--amber)}
|
|
101
|
+
.lnk .lst{font-size:11px;color:var(--faint)}.lnk .lmore,.lnk .lrm{background:none;border:0;color:var(--faint);cursor:pointer;font-size:14px;padding:0 2px}.lnk .lrm:hover{color:var(--amber)}
|
|
98
102
|
.agent{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
|
|
99
103
|
.adot{width:10px;height:10px;border-radius:50%;display:inline-block;flex:none}
|
|
100
104
|
.adot.run{background:var(--amber);animation:pulse 1s ease-in-out infinite}
|
|
@@ -140,7 +144,8 @@ label.check input{width:auto}
|
|
|
140
144
|
.bcard .bc{font-size:26px;font-weight:700;color:var(--faint);margin-top:6px}.bcard.has .bc{color:var(--green)}
|
|
141
145
|
.bcard .bl{font-size:12px;color:var(--faint)}
|
|
142
146
|
.bcard .bi{display:flex;gap:8px;align-items:flex-start;padding:7px 0;border-top:1px solid var(--line);font-size:13px;color:var(--text)}
|
|
143
|
-
.bcard .bi .t{flex:1;min-width:0;overflow-wrap:anywhere}.bcard .bi button{padding:4px 9px;font-size:12px}
|
|
147
|
+
.bcard .bi{flex-wrap:wrap}.bcard .bi .t{flex:1 1 150px;min-width:0;overflow-wrap:anywhere}.bcard .bi button{padding:4px 9px;font-size:12px}
|
|
148
|
+
.bcard .tchat{border-top:1px solid var(--line);border-radius:9px;margin-top:8px}
|
|
144
149
|
.bcard .bbrief{white-space:pre-line;margin:8px 0;padding:8px 10px;border-left:3px solid var(--green);background:var(--ink3);border-radius:0 8px 8px 0;font-size:13px;color:var(--bone)}
|
|
145
150
|
@media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
|
|
146
151
|
@media(max-width:600px){#panel-tasks .row>#newtask,#screenname,#pagesite{flex:1 1 100%!important}.tabs{overflow-x:auto;scrollbar-width:none;padding:0 8px;gap:0}.tabs::-webkit-scrollbar{display:none}.tab{flex:none;padding:9px 11px}header{padding:12px 14px 8px}main{padding:12px}}
|
|
@@ -181,7 +186,7 @@ label.check input{width:auto}
|
|
|
181
186
|
<div id="review" class="review hidden"></div>
|
|
182
187
|
<div style="margin-top:22px;border-top:1px solid var(--line);padding-top:4px">
|
|
183
188
|
<div class="tgroup">Screens <span class="tcount">experimental · blueprints for screen automation</span></div>
|
|
184
|
-
<div class="note muted" style="margin-top:2px">Capture a screen, then drag a box over each part that matters (a button, a field, a menu) and name it. Each region keeps its pixel coordinates and its centre, ready for automation to aim at. With more than one display, pick which one to capture, or one screen per display. <b>Click here</b> on a region clicks its centre on your real screen, after you confirm. Or let Symbiot do it all for a web page: <b>Map page</b> opens it in a hidden browser, takes its screenshot and marks every button, link and field by itself, and <b>Press</b> on one of them follows it there and maps the next page; <b>Type</b> on a field types into it there. A map only has what fits in the window, so on a longer page <b>Scroll down</b> maps the next part, or <b>Whole page</b> maps all of it in one tall screenshot (a
|
|
189
|
+
<div class="note muted" style="margin-top:2px">Capture a screen, then drag a box over each part that matters (a button, a field, a menu) and name it. Each region keeps its pixel coordinates and its centre, ready for automation to aim at. With more than one display, pick which one to capture, or one screen per display. <b>Click here</b> on a region clicks its centre on your real screen, after you confirm. Or let Symbiot do it all for a web page: <b>Map page</b> opens it in a hidden browser, takes its screenshot and marks every button, link and field by itself, and <b>Press</b> on one of them follows it there and maps the next page; <b>Type</b> on a field types into it there. A map only has what fits in the window, so on a longer page <b>Scroll down</b> maps the next part, or <b>Whole page</b> maps all of it in one tall screenshot (where a list scrolls inside the page, like Gmail's mail, that list opened out: a whole inbox on one screen). Only a mapped page is typed into: nothing types on your real screen. <b>Watch</b> on a mapped page (your inbox, say) has Symbiot read it again every few minutes while it runs and tell you what's new there.</div>
|
|
185
190
|
<div class="row" style="margin-top:10px"><input id="pagesite" placeholder="a web page to map by itself: gmail, github.com/pulls or a web address" style="flex:1"><button class="act" id="mappage">Map page</button><button class="ghost" id="pagesignin" title="open this site in Symbiot's own browser as a window, to sign in once; close it when you're done">Sign in</button></div>
|
|
186
191
|
<div class="row" style="margin-top:10px"><input id="screenname" placeholder="name the screen, e.g. GitHub PR page" style="flex:1"><select id="screenwhich" class="hidden" title="which display to capture" style="flex:0 0 auto;width:auto"></select><select id="screendelay" title="wait first, so you can bring the right window to the front" style="flex:0 0 auto;width:auto"><option value="0">now</option><option value="3">in 3s</option><option value="5">in 5s</option><option value="10">in 10s</option></select><button class="ghost" id="capture">Capture screen</button><button class="ghost" id="screenload" title="use a PNG screenshot you already have">Load image</button><input type="file" id="screenfile" accept="image/png" class="hidden"></div>
|
|
187
192
|
<div id="screenmsg"></div>
|
|
@@ -193,6 +198,7 @@ label.check input{width:auto}
|
|
|
193
198
|
<section id="panel-board" class="hidden">
|
|
194
199
|
<div class="row"><span class="muted" id="boardsum" style="flex:1">Everything you watch, side by side.</span><select id="boardhours" title="how far back" style="flex:0 0 auto;width:auto"><option value="24">last 24 hours</option><option value="72">last 3 days</option><option value="168">last 7 days</option></select><button class="ghost" id="boardrefresh">Refresh</button></div>
|
|
195
200
|
<div id="boardmsg"></div>
|
|
201
|
+
<div id="boardlinks"></div>
|
|
196
202
|
<div id="board" class="board"></div>
|
|
197
203
|
</section>
|
|
198
204
|
<section id="panel-run" class="hidden">
|
|
@@ -264,6 +270,13 @@ label.check input{width:auto}
|
|
|
264
270
|
</div>
|
|
265
271
|
</div>
|
|
266
272
|
<div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
|
|
273
|
+
<label>Link your work</label>
|
|
274
|
+
<div class="note muted" style="margin-top:2px">One click per site you work in: it opens the site in Symbiot's own browser, where you sign in the usual way (its own login, SSO and 2FA; Symbiot never sees your password). Symbiot then trusts the site and watches its inbox or notifications, so what arrives there shows on the Dashboard and in Week and Standup. Close the window when you're signed in.</div>
|
|
275
|
+
<div id="links"></div>
|
|
276
|
+
<div id="linksmsg"></div>
|
|
277
|
+
<div class="note muted" id="linksnote">For a whole team, one <b>links.json</b> in Symbiot's config folder adds your company's own sites and hides the ones you don't use, so everyone gets the same buttons.</div>
|
|
278
|
+
</div>
|
|
279
|
+
<div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
|
|
267
280
|
<label>Trusted sites for Screens <span class="muted">(experimental)</span></label>
|
|
268
281
|
<div id="trustedsites"></div>
|
|
269
282
|
<div class="row" style="margin-top:6px"><input id="newtrusted" placeholder="a site, e.g. mail.google.com or github.com" style="flex:1"><button class="ghost" id="addtrusted">Trust site</button></div>
|
|
@@ -311,7 +324,7 @@ function tabs(){return document.querySelectorAll('.tab');}
|
|
|
311
324
|
function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
|
|
312
325
|
var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isBoard=tab==='board',isRun=(tab==='week'||tab==='standup'||tab==='todo');
|
|
313
326
|
$('panel-map').classList.toggle('hidden',!isMap);
|
|
314
|
-
$('panel-board').classList.toggle('hidden',!isBoard);if(isBoard)loadBoard();
|
|
327
|
+
$('panel-board').classList.toggle('hidden',!isBoard);if(isBoard){loadBoard();loadLinks();}
|
|
315
328
|
$('panel-run').classList.toggle('hidden',!isRun);
|
|
316
329
|
$('panel-settings').classList.toggle('hidden',!isSet);
|
|
317
330
|
$('panel-tasks').classList.toggle('hidden',!isTasks);
|
|
@@ -532,11 +545,16 @@ if(!ans.length){msg.innerHTML="<div class='note err'>Pick an option or type an a
|
|
|
532
545
|
box.querySelectorAll('button').forEach(function(b){b.disabled=true;});
|
|
533
546
|
api('/api/agents/answer',{path:a.path,answers:ans,rerun:rerun}).then(function(r){
|
|
534
547
|
if(!r||r.error){box.querySelectorAll('button').forEach(function(b){b.disabled=false;});msg.innerHTML="<div class='note err'>"+esc((r&&r.error)||'failed')+"</div>";return;}
|
|
535
|
-
msg.innerHTML="<div class='note ok'>✓ Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" · your agent is picking them up now.":".")+(r.note?"<div class='muted'>"+esc(r.note)+"</div>":"")+"</div>";loadAgents();});}
|
|
548
|
+
msg.innerHTML="<div class='note ok'>✓ Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" · your agent is picking them up now.":".")+(r.yours&&r.yours.length?"<div class='err'>👤 Still yours to do: "+esc(r.yours.join(' '))+"</div>":"")+(r.note?"<div class='muted'>"+esc(r.note)+"</div>":"")+"</div>";loadAgents();});}
|
|
536
549
|
var s1=box.querySelector('.qsend'),s2=box.querySelector('.qsave');
|
|
537
550
|
if(s1)s1.addEventListener('click',function(){send(true);});if(s2)s2.addEventListener('click',function(){send(false);});
|
|
538
551
|
box.querySelectorAll('.qidea').forEach(function(btn){btn.addEventListener('click',function(){var s=a.ask.suggestions[+btn.getAttribute('data-i')];if(!s)return;btn.disabled=true;
|
|
539
552
|
api('/api/tasks/add',{text:s.text,repo:s.repo||a.name}).then(function(){s.added=true;btn.outerHTML="<span class='tag'>in Tasks</span>";});});});});}
|
|
553
|
+
// A step of yours an answer picked (👤 You): the next run waits for it (agents.mjs waitingFor)
|
|
554
|
+
function waitHtml(a){var w=a.waiting;if(!w)return '';var fs=(w.files||[]).map(function(f){return "<code>"+esc(f)+"</code>";}).join(" or ");
|
|
555
|
+
return "<div class='aq'><h4>⏸ Waiting on your step</h4><div class='qt'>👤 "+esc(w.step)+"</div><div class='qc'>"+(fs?"Your agent "+(w.rerun?"starts by itself":"can start")+" once "+fs+" changes"+(w.rerun?", while Symbiot runs":"")+". Done it some other way?":"Your agent waits for it, so it doesn't stop on the same questions again.")+" Start it now once it's done.</div><div class='row'><button class='act wstart' data-id='"+esc(a.id)+"'>Start it now</button></div></div>";}
|
|
556
|
+
function wireWaits(el){el.querySelectorAll('.wstart').forEach(function(btn){btn.addEventListener('click',function(){var a=agentById(btn.getAttribute('data-id'));if(!a)return;btn.disabled=true;
|
|
557
|
+
api('/api/open',{path:a.path,force:true}).then(function(x){var msg=document.getElementById('agentsmsg');msg.innerHTML=x&&x.opened?"<div class='note ok'>✓ Started your agent in <b>"+esc(a.name)+"</b>.</div>":"<div class='note err'>"+esc(a.name)+": "+(x&&x.busy?"an agent is already running there.":"it didn't start. Check the command in Settings.")+"</div>";loadAgents();}).catch(function(e){btn.disabled=false;document.getElementById('agentsmsg').innerHTML="<div class='note err'>"+esc(String((e&&e.message)||e))+"</div>";});});});}
|
|
540
558
|
function answering(){var f=document.activeElement;return !!(f&&f.closest&&f.closest('.aq'));}
|
|
541
559
|
function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
|
|
542
560
|
if(!list||!list.length){AGENTLIST=[];el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) — you'll watch it work here.</div>";stopAgentsPoll();return;}
|
|
@@ -546,6 +564,8 @@ saveDrafts(el);AGENTLIST=list;
|
|
|
546
564
|
el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==='done'?'ok':'fail');
|
|
547
565
|
var st=a.status==='running'?('working · '+fmtE(a.elapsed)):(esc(a.status)+' · '+fmtE(a.elapsed)+(a.exitCode!=null?' · exit '+a.exitCode:''));
|
|
548
566
|
if(a.fromHeld)st+=" · started on the tasks held for the last run";
|
|
567
|
+
if(a.earlier)st+=" · "+(a.status==='running'?"started outside this window":"ran before Symbiot last started");
|
|
568
|
+
if(a.waiting)st+=" · <span style='color:var(--amber)'>waiting on your step</span>";
|
|
549
569
|
if(nQs(a))st+=" · <span style='color:var(--amber)'>needs your answers</span>";
|
|
550
570
|
if(a.held!=null){var hn=a.held.length,ht=a.held.map(function(t){return "• "+esc(t).replace(/'/g,''');}).join(' ');
|
|
551
571
|
ht+=(hn?' ':'')+(a.status==='running'?"Sent while this agent was running. They wait in .symbiot/TASKS.next.md, replace TASKS.md when it finishes (keeping its ticks), and an agent starts on them then.":"They wait in .symbiot/TASKS.next.md for the agent running in this folder to finish. After that, an agent starts on them the next time the Tasks tab checks this repo, or send again.");
|
|
@@ -562,11 +582,11 @@ if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
|
|
|
562
582
|
}else if(a.status==='done'){
|
|
563
583
|
b+="<div class='changed muted'>No file changes detected (the agent may have only planned or asked).</div>";
|
|
564
584
|
}
|
|
565
|
-
b+=askHtml(a);
|
|
585
|
+
b+=waitHtml(a)+askHtml(a);
|
|
566
586
|
b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
|
|
567
587
|
return "<div class='agent'>"+b+"</div>";}).join("");
|
|
568
588
|
el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
|
|
569
|
-
restoreDrafts(el);wireAsks(el);
|
|
589
|
+
restoreDrafts(el);wireAsks(el);wireWaits(el);
|
|
570
590
|
stopAgentsPoll();if(anyRunning&¤t==='agents')agentsTimer=setTimeout(loadAgents,2000);});}
|
|
571
591
|
function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos…</div>";
|
|
572
592
|
var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
|
|
@@ -651,6 +671,33 @@ box.querySelectorAll('.punpair').forEach(function(b){b.addEventListener('click',
|
|
|
651
671
|
var pp=$('ppair');if(pp)pp.addEventListener('click',function(){var a=$('paddr').value,cd=$('pcode').value;pp.disabled=true;pp.textContent='Pairing...';api('/api/phone/pair',{address:a,code:cd}).then(function(x){renderPhoneLink(x,x&&x.error?{text:x.error}:{ok:true,text:'Paired. What Watch finds on '+x.name+' shows up here as a notification.'});if(x&&x.error&&$('paddr')){$('paddr').value=a;$('pcode').value=cd;}});});
|
|
652
672
|
var pc=$('pcheck');if(pc)pc.addEventListener('click',function(){pc.disabled=true;api('/api/phone/check',{}).then(function(x){renderPhoneLink(x,x&&!x.error?{ok:true,text:x.shown?'Showed '+x.shown+' notification(s).':'Asked: nothing new.'}:null);});});
|
|
653
673
|
var pf=$('pforget');if(pf)pf.addEventListener('click',function(){if(typeof confirm==='function'&&!confirm('Forget your computer? Its notifications stop.'))return;api('/api/phone/forget',{}).then(function(x){renderPhoneLink(x);});});}
|
|
674
|
+
// Link your work (links.mjs): one button per standard site. A click opens it in
|
|
675
|
+
// Symbiot's browser to sign in, trusts it and watches it. Its dot: grey not
|
|
676
|
+
// linked, amber waiting for you to sign in (or signed out), green linked. Shown
|
|
677
|
+
// in Settings, and on the Dashboard until something is linked.
|
|
678
|
+
var LINKS=null;
|
|
679
|
+
var LINKWORD={ok:'linked',signin:'sign in, then close the window',signedout:'signed out',error:'needs a fix'};
|
|
680
|
+
function escQ(t){return esc(t).replace(/'/g,''');}
|
|
681
|
+
function linksHtml(d,board){var h=board?"<div class='note muted'>Link your work to see what arrives there here, and in Week and Standup:</div>":"";
|
|
682
|
+
if(d.error)h+="<div class='note err'>"+esc(d.error)+"</div>";
|
|
683
|
+
d.groups.forEach(function(g){h+="<div class='lgroup'><span class='fl'>"+esc(g)+"</span>";
|
|
684
|
+
d.items.filter(function(x){return x.group===g;}).forEach(function(it){var on=it.state!=='off';
|
|
685
|
+
h+="<span class='lnk "+escQ(it.state)+"' data-id='"+escQ(it.id)+"'><button class='fchip lbtn' title='"+escQ(it.note||(on?'open '+it.url+' to sign in again':'sign in at '+it.url+' and link it'))+"'><span class='dot-c' style='background:"+(it.state==='ok'?'var(--green)':on?'var(--amber)':'var(--line)')+"'></span>"+esc(it.name)+"</button>"+
|
|
686
|
+
(on?"<span class='lst'>"+esc(LINKWORD[it.state]||'')+"</span><button class='lmore' title='check it now'>↻</button><button class='lrm' title='unlink: stop watching it and stop trusting it'>×</button>":"")+"</span>";});
|
|
687
|
+
h+="</div>";});return h;}
|
|
688
|
+
function linksOut(html){var o=document.getElementById('linksmsg');if(o)o.innerHTML=html;var b=document.getElementById('boardmsg');if(b&&LINKS&&!LINKS.linked)b.innerHTML=html;}
|
|
689
|
+
function renderLinks(){if(!LINKS)return;['links','boardlinks'].forEach(function(id){var el=document.getElementById(id);if(!el)return;
|
|
690
|
+
if(id==='boardlinks'&&LINKS.linked){el.innerHTML='';return;}
|
|
691
|
+
el.innerHTML=linksHtml(LINKS,id==='boardlinks');
|
|
692
|
+
el.querySelectorAll('.lnk').forEach(function(sp){var lid=sp.getAttribute('data-id'),name=sp.querySelector('.lbtn').textContent;
|
|
693
|
+
sp.querySelector('.lbtn').addEventListener('click',function(){linksOut("<div class='note muted'>Opening "+esc(name)+"…</div>");
|
|
694
|
+
api('/api/links/link',{id:lid}).then(function(r){if(!r||r.error){linksOut("<div class='note err'>"+esc(name)+": "+esc((r&&r.error)||'failed')+"</div>");loadLinks();return;}
|
|
695
|
+
linksOut("<div class='note ok'>Opened "+esc(name)+" in Symbiot's browser. Sign in there as you normally do, then close that window: Symbiot checks it within a minute.</div>");loadLinks();
|
|
696
|
+
[20000,60000,120000].forEach(function(ms){setTimeout(loadLinks,ms);});});});
|
|
697
|
+
var m=sp.querySelector('.lmore');if(m)m.addEventListener('click',function(){m.disabled=true;api('/api/links/check',{id:lid}).then(function(r){m.disabled=false;
|
|
698
|
+
if(r&&r.busy)linksOut("<div class='note muted'>Symbiot's browser is busy (a sign-in window is open?). Close it, then check again.</div>");else if(r&&r.error)linksOut("<div class='note err'>"+esc(r.error)+"</div>");loadLinks();loadBoard();});});
|
|
699
|
+
var x=sp.querySelector('.lrm');if(x)x.addEventListener('click',function(){api('/api/links/unlink',{id:lid}).then(function(){linksOut('');loadLinks();loadTrusted();loadBoard();});});});});}
|
|
700
|
+
function loadLinks(){api('/api/links').then(function(d){if(d&&d.items){LINKS=d;renderLinks();}});}
|
|
654
701
|
// Trusted sites: where Screens' Press and Type don't ask first (headless.mjs).
|
|
655
702
|
function loadTrusted(){api('/api/screens/trusted').then(function(d){var box=document.getElementById('trustedsites');var sites=(d&&d.sites)||[];
|
|
656
703
|
box.innerHTML=sites.length?sites.map(function(h){return "<div class='task' data-h='"+esc(h)+"'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(h)+"</span><button class='rm rmtrusted' title='stop trusting it: press and type ask first again'>×</button></div>";}).join(""):"<div class='muted' style='font-size:12px'>None yet: Press and Type ask first on every site.</div>";
|
|
@@ -724,7 +771,7 @@ function scrBox(r,s,cls){return "<div class='scrbox"+(cls?" "+cls:"")+"' style='
|
|
|
724
771
|
function renderScreen(){var v=$('screenview'),s=SCREEN;if(!s){v.innerHTML='';return;}
|
|
725
772
|
var h="<div class='row' style='margin-top:10px'><input id='scrname' title='rename this screen' style='flex:1'><span class='muted'>"+(s.monitor?esc(s.monitor.name)+(s.monitor.where?" ("+esc(s.monitor.where)+")":"")+" · ":"")+s.w+" × "+s.h+" px</span>"+(s.page?"<button class='ghost' id='scrremap' title='open this page in the hidden browser again and map it as it is now'>Map again</button>"+(watchOf(s)?"<button class='ghost' id='scrwatch' title='Symbiot reads this page every few minutes and tells you what's new: click to stop'>Watching ✓</button>":"<button class='ghost' id='scrwatch' title='read this page again every few minutes while Symbiot runs, and tell me what's new on it'>Watch</button>"):"")+(!s.monitor&&!s.page&&MONITORS.length>1?"<button class='ghost' id='scrsplit' title='cut this screenshot into one screen per display, regions included (this one stays)'>Split by display</button>":"")+"<button class='ghost' id='scrcopy' title='copy the regions and their coordinates as JSON'>Copy blueprint</button><button class='ghost' id='scrdel'>Delete</button></div>";
|
|
726
773
|
// a page taller than its window: where this screen is on it, and Scroll to map the rest
|
|
727
|
-
var sc=s.page&&s.page.scroll,up=sc&&sc.y>0,down=sc&&sc.y<sc.max,whole
|
|
774
|
+
var sc=s.page&&s.page.scroll,up=sc&&sc.y>0,down=sc&&sc.y<sc.max,whole=!!sc; // whole: the page, or a list inside it (Gmail's) opened out
|
|
728
775
|
if(s.page)h+="<div class='row' style='margin-top:6px'><span class='muted' style='font-size:12px;flex:1;overflow-wrap:anywhere'>Mapped in the hidden browser: "+esc(s.page.url)+(sc?" · <b>"+(up&&down?"more above and below":down?"more below":"the end of the page")+"</b>: a map only has what fits in the window, so scroll to map the rest"+(whole?", or map the whole page at once":""):s.page.full?" · <b>the whole page</b>, in one tall screenshot":"")+"</span>"+(up?"<button class='ghost scrscroll' data-to='up' title='scroll up in the hidden browser and map what's there as a new screen'>↑ Scroll up</button>":"")+(down?"<button class='ghost scrscroll' data-to='down' title='scroll down in the hidden browser and map what's there as a new screen'>Scroll down ↓</button>":"")+(whole?"<button class='ghost scrwhole' title='map all of this page in one tall screenshot, every button, link and field on it, as a new screen'>Whole page ↕</button>":"")+"</div>";
|
|
729
776
|
h+="<div class='scrwrap' id='scrwrap'><img src='/api/screens/image?id="+encodeURIComponent(s.id)+"&t="+encodeURIComponent(T)+"' alt='' draggable='false'>"+(s.regions||[]).map(function(r){return scrBox(r,s,'');}).join('')+(SPEND?scrBox(SPEND,s,'draw'):"")+"<div class='scrbox draw hidden' id='scrdraw'></div></div>";
|
|
730
777
|
h+="<div class='mapbar' id='scrbar'>Drag on the screenshot to mark a region · coordinates are screenshot pixels</div>";
|
|
@@ -783,7 +830,7 @@ if(!gh)h+="<div class='row' style='margin-top:8px'><span class='muted' style='fo
|
|
|
783
830
|
if(ws.length){h+="<label class='check' style='margin-top:8px'><input type='checkbox' id='wbrief'"+(WATCH.brief?" checked":"")+"> <span>Brief me: the AI connected in Settings says what needs me and what can wait, here and in the notification. <span class='muted'>It's sent what's new: for mail, the sender, subject and preview. With a local Ollama model nothing leaves this computer.</span></span></label>";
|
|
784
831
|
var b0=bs[0]&&ns.some(function(n){return n.ts===bs[0].ts&&n.watch===bs[0].watch;})?bs[0]:null;
|
|
785
832
|
if(b0)h+="<div class='note' style='white-space:pre-line;margin-top:6px'><b>Brief</b> <span class='muted' style='font-size:12px'>"+esc(b0.name)+" · "+b0.count+" new · "+agoTxt(b0.ts)+"</span><br>"+esc(b0.text)+"</div>";
|
|
786
|
-
h+=ns.length?"<div class='row' style='margin-top:8px'><span class='fl' style='margin-left:0;flex:1'>New · "+ns.length+"</span><button class='ghost' id='wclear' title='clear this list (Symbiot still remembers what it has seen)'>Clear</button></div>"+ns.slice(0,20).map(function(n,i){return "<div class='task' data-i='"+i+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:12px'>"+esc(n.name)+" · "+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost wopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost wdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent opens it in your inbox through Screens, writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted · again":"Draft a reply")+"</button>":"")+"</div>";}).join('')
|
|
833
|
+
h+=ns.length?"<div class='row' style='margin-top:8px'><span class='fl' style='margin-left:0;flex:1'>New · "+ns.length+"</span><button class='ghost' id='wclear' title='clear this list (Symbiot still remembers what it has seen)'>Clear</button></div>"+ns.slice(0,20).map(function(n,i){return "<div class='task' data-i='"+i+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:12px'>"+esc(n.name)+" · "+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost wopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost wdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent opens it in your inbox through Screens, writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted · again":"Draft a reply")+"</button>":"")+(n.chat&&n.drafted?"<button class='ghost wopenwa' title='"+OPEN_WA_TIP+"'>Open in WhatsApp</button>":"")+"</div>";}).join('')
|
|
787
834
|
:"<div class='muted' style='font-size:12px;margin-top:6px'>Nothing new yet. New rows show up here, and as a notification. Standup counts what's waiting on you, and your agent reads them with <b>symbiot watch new</b>.</div>";}
|
|
788
835
|
box.innerHTML=h;
|
|
789
836
|
var wg=$('wgithub');if(wg)wg.addEventListener('click',function(){wg.disabled=true;api('/api/watch/add',{site:'github',every:5}).then(function(x){wg.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
|
|
@@ -799,16 +846,26 @@ box.querySelectorAll('.wopen').forEach(function(b){b.addEventListener('click',fu
|
|
|
799
846
|
box.querySelectorAll('.wdraft').forEach(function(b){b.addEventListener('click',function(){var n=ns[+b.closest('.task').getAttribute('data-i')];b.disabled=true;
|
|
800
847
|
api('/api/watch/draft',{id:n.id}).then(function(x){b.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
|
|
801
848
|
$('screenmsg').innerHTML="<div class='note ok'>"+(x.chat?DRAFTING_CHAT:DRAFTING)+"</div>";loadWatchUI();}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});});});
|
|
849
|
+
box.querySelectorAll('.wopenwa').forEach(function(b){b.addEventListener('click',function(){openChatUI(ns[+b.closest('.task').getAttribute('data-i')],b,function(cls,html){$('screenmsg').innerHTML="<div class='note "+cls+"'>"+html+"</div>";});});});
|
|
802
850
|
var wc=$('wclear');if(wc)wc.addEventListener('click',function(){api('/api/watch/clear',{}).then(loadWatchUI);});}
|
|
803
851
|
// what Check now found, for Watching and the Dashboard
|
|
804
852
|
function watchCheckMsg(w,x){return x.busy?"The hidden browser is busy with a map, press or type. It reads "+esc(w.name)+" once that's done.":x.learned!=null?"Read "+esc(x.name)+": learned the "+x.learned+" things listed there now. From here on, anything new is noted.":x["new"]&&x["new"].length?"✓ "+x["new"].length+" new on "+esc(x.name)+".":"Read "+esc(x.name)+": nothing new.";}
|
|
805
853
|
// a chat (WhatsApp): the reply is typed into the chat's message box in Symbiot's browser, unsent
|
|
806
854
|
var DRAFT_CHAT_TIP="your coding agent opens the chat in Symbiot's hidden browser and types a reply into its message box, unsent. It never presses Send or Enter";
|
|
807
|
-
var DRAFTING_CHAT="✓ Your agent is drafting a reply in that chat. It opens WhatsApp in Symbiot's hidden browser, types the reply into the chat's message box and leaves it there unsent: it never presses Send or Enter. Once it's done (the Agents tab),
|
|
855
|
+
var DRAFTING_CHAT="✓ Your agent is drafting a reply in that chat. It opens WhatsApp in Symbiot's hidden browser, types the reply into the chat's message box and leaves it there unsent: it never presses Send or Enter. Once it's done (the Agents tab), click <b>Open in WhatsApp</b> on the message: the chat shows the reply in its box, for you to read and send.";
|
|
856
|
+
// Open in WhatsApp (watch.mjs openChat): Symbiot's browser, as a window, at web.whatsapp.com
|
|
857
|
+
var OPEN_WA_TIP="open WhatsApp in Symbiot's browser, where the chat shows your agent's reply in its message box, for you to read and send";
|
|
858
|
+
function openChatUI(n,b,say){if(!n)return;b.disabled=true;
|
|
859
|
+
api('/api/watch/open-chat',{id:n.id}).then(function(x){b.disabled=false;if(!x||x.error){say('err',esc((x&&x.error)||'failed'));return;}
|
|
860
|
+
say('ok',"✓ Opened WhatsApp in Symbiot's browser, in a window of its own. The chat shows your agent's reply in its message box: read it, change it if you like, and send it there. Close the window when you're done, so Watch can read WhatsApp again.");}).catch(function(e){b.disabled=false;say('err',esc(String((e&&e.message)||e)));});}
|
|
808
861
|
var DRAFTING="✓ Your agent is drafting a reply to it. It opens the email in your inbox through Screens, writes the reply and leaves it in Drafts for you to read and send: it never presses Send. Follow it, and answer anything it asks, in the Agents tab.";
|
|
809
862
|
// ---- Dashboard: a card per page you watch (your inbox, GitHub, WhatsApp…), what's new on each ----
|
|
810
863
|
var BOARD=null;var BOARDICON={mail:'✉️',github:'🔔',chat:'💬',page:'🌐'};
|
|
811
|
-
|
|
864
|
+
// Talk it over (watch.mjs boardChat): a chat on a card, open by its id, with what's half typed kept across a refresh
|
|
865
|
+
var BTALK={},BTALKDRAFT={},BTALKBUSY=0;
|
|
866
|
+
var TALKHINT="<div class='muted' style='font-size:12px'>Go over what's new here with your AI: what needs you, and what to say to whom. Then click <b>Draft a reply</b> on one, and your agent writes it the way you agreed here. Your AI is sent what this card lists (for mail: the sender, subject and preview).</div>";
|
|
867
|
+
function talking(){var f=document.activeElement;return BTALKBUSY>0||!!(f&&f.closest&&f.closest('.btalk'));}
|
|
868
|
+
function loadBoard(){api('/api/watch/board?hours='+(+$('boardhours').value||24)).then(function(b){if(b&&b.cards){BOARD=b;if(!talking())renderBoard();}});}
|
|
812
869
|
function boardMsg(cls,html){$('boardmsg').innerHTML="<div class='note "+cls+"'>"+html+"</div>";}
|
|
813
870
|
function renderBoard(){var b=BOARD,cs=b.cards,el=$('board');
|
|
814
871
|
var tab=$('boardtab');if(tab)tab.textContent='Dashboard'+(b.total?' · '+b.total:'');
|
|
@@ -818,14 +875,29 @@ $('boardgo').addEventListener('click',function(){setTab('map');var w=$('watchbox
|
|
|
818
875
|
var when=b.hours===24?'since yesterday':b.hours===72?'in the last 3 days':'in the last 7 days';
|
|
819
876
|
var waiting=cs.filter(function(c){return c.count;}).map(function(c){return c.label+(c.source==='page'?' on '+c.name:'');});
|
|
820
877
|
$('boardsum').innerHTML=waiting.length?"<b style='color:var(--bone)'>Waiting on you "+when+":</b> "+esc(waiting.join(', ')):"Nothing new "+when+" on the "+cs.length+" thing"+(cs.length===1?"":"s")+" you watch.";
|
|
821
|
-
el.innerHTML=cs.map(function(c,i){
|
|
822
|
-
return "<div class='bcard"+(c.count?" has":"")+"' data-i='"+i+"'><div class='bh'><span>"+(BOARDICON[c.source]||BOARDICON.page)+"</span><span class='bn' title='"+esc(c.url)+"'>"+esc(c.name)+"</span>"+(c.count?"<button class='ghost bseen' title='set this card back to 0: only what comes in after counts. It stays under Watching, and the other cards keep theirs' style='padding:4px 9px;font-size:12px'>Seen</button>":"")+"<button class='ghost bcheck' title='read it now' style='padding:4px 9px;font-size:12px'>Check now</button></div>"
|
|
878
|
+
el.innerHTML=cs.map(function(c,i){var nt=Math.ceil(((c.chat||[]).length)/2);
|
|
879
|
+
return "<div class='bcard"+(c.count?" has":"")+"' data-i='"+i+"'><div class='bh'><span>"+(BOARDICON[c.source]||BOARDICON.page)+"</span><span class='bn' title='"+esc(c.url)+"'>"+esc(c.name)+"</span>"+(c.count?"<button class='ghost bseen' title='set this card back to 0: only what comes in after counts. It stays under Watching, and the other cards keep theirs' style='padding:4px 9px;font-size:12px'>Seen</button>":"")+"<button class='ghost btalkbtn' title='talk what's new here over with your AI before you draft a reply' style='padding:4px 9px;font-size:12px'>💬"+(nt?" "+nt:"")+"</button><button class='ghost bcheck' title='read it now' style='padding:4px 9px;font-size:12px'>Check now</button></div>"
|
|
823
880
|
+"<div class='bc'>"+c.count+"</div><div class='bl'>"+esc(c.count?c.label.replace(/^\\d+ /,''):'nothing new')+" · "+(c.checked?"read "+agoTxt(c.checked):c.last?"tried "+agoTxt(c.last):"first read within a minute")+(c.via==='gh'?" · through gh":"")+"</div>"
|
|
824
881
|
+(c.error?"<div class='note err'>"+esc(c.error)+"</div>":"")
|
|
825
882
|
+(c.brief?"<div class='bbrief'>"+esc(c.brief.text)+"</div>":"")
|
|
826
|
-
+c.items.map(function(n,j){return "<div class='bi' data-j='"+j+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost bopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost bdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted · again":"Draft a reply")+"</button>":"")+"</div>";}).join('')
|
|
827
|
-
+(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>…and "+(c.count-c.items.length)+" more</div>":"")
|
|
883
|
+
+c.items.map(function(n,j){return "<div class='bi' data-j='"+j+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost bopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost bdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted · again":"Draft a reply")+"</button>":"")+(n.chat&&n.drafted?"<button class='ghost bopenwa' title='"+OPEN_WA_TIP+"'>Open in WhatsApp</button>":"")+"</div>";}).join('')
|
|
884
|
+
+(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>…and "+(c.count-c.items.length)+" more</div>":"")
|
|
885
|
+
+(BTALK[c.id]?"<div class='tchat btalk'><div class='msgs'>"+(nt?chatMsgs(c.chat):TALKHINT)+"</div><div class='row'><input class='talkq' placeholder='Ask about these, or say how to answer one...' style='flex:1'><button class='ghost talksend'>Ask</button><a class='talkclear' title='forget this conversation'>clear</a></div></div>":"")+"</div>";}).join('');
|
|
828
886
|
function card(btn){return cs[+btn.closest('.bcard').getAttribute('data-i')];}
|
|
887
|
+
el.querySelectorAll('.btalkbtn').forEach(function(btn){btn.addEventListener('click',function(){var c=card(btn);BTALK[c.id]=!BTALK[c.id];renderBoard();var q=BTALK[c.id]&&el.querySelector(".bcard[data-i='"+cs.indexOf(c)+"'] .talkq");if(q&&q.focus)q.focus();});});
|
|
888
|
+
el.querySelectorAll('.btalk').forEach(function(box){var c=card(box),inp=box.querySelector('.talkq'),send=box.querySelector('.talksend'),msgs=box.querySelector('.msgs');
|
|
889
|
+
inp.value=BTALKDRAFT[c.id]||'';inp.addEventListener('input',function(){BTALKDRAFT[c.id]=inp.value;});
|
|
890
|
+
function ask(){var q=(inp.value||'').trim();if(!q||send.disabled)return;send.disabled=true;inp.value='';BTALKDRAFT[c.id]='';BTALKBUSY++;
|
|
891
|
+
if(!(c.chat&&c.chat.length))msgs.innerHTML='';
|
|
892
|
+
msgs.insertAdjacentHTML('beforeend',chatMsgs([{role:'user',text:q}])+"<div class='msg a muted thinking'>Thinking…</div>");
|
|
893
|
+
function done(){BTALKBUSY=Math.max(0,BTALKBUSY-1);send.disabled=false;var th=msgs.querySelector('.thinking');if(th)th.remove();}
|
|
894
|
+
api('/api/watch/chat',{id:c.id,question:q}).then(function(r){done();
|
|
895
|
+
if(r.error==='not-connected'){msgs.insertAdjacentHTML('beforeend',"<div class='msg a'>Connect a model in Settings to talk it over - Ollama is free and runs locally.</div>");return;}
|
|
896
|
+
if(r.error){msgs.insertAdjacentHTML('beforeend',"<div class='msg a err'>"+esc(r.error)+"</div>");return;}
|
|
897
|
+
c.chat=r.chat||[];msgs.innerHTML=chatMsgs(c.chat);var tb=box.closest('.bcard').querySelector('.btalkbtn');if(tb)tb.innerHTML='💬 '+Math.ceil(c.chat.length/2);}).catch(function(e){done();msgs.insertAdjacentHTML('beforeend',"<div class='msg a err'>"+esc(String((e&&e.message)||e))+"</div>");});}
|
|
898
|
+
send.addEventListener('click',ask);inp.addEventListener('keydown',function(e){if(e.key==='Enter')ask();});
|
|
899
|
+
box.querySelector('.talkclear').addEventListener('click',function(){api('/api/watch/chat/clear',{id:c.id}).then(function(){c.chat=[];msgs.innerHTML=TALKHINT;var tb=box.closest('.bcard').querySelector('.btalkbtn');if(tb)tb.innerHTML='💬';});});});
|
|
900
|
+
el.querySelectorAll('.bopenwa').forEach(function(btn){btn.addEventListener('click',function(){openChatUI(item(btn),btn,boardMsg);});});
|
|
829
901
|
function item(btn){return card(btn).items[+btn.closest('.bi').getAttribute('data-j')];}
|
|
830
902
|
el.querySelectorAll('.bcheck').forEach(function(btn){btn.addEventListener('click',function(){var c=card(btn);btn.disabled=true;btn.textContent='Reading…';
|
|
831
903
|
api('/api/watch/check',{id:c.id}).then(function(x){if(!x||x.error)boardMsg('err',esc((x&&x.error)||'failed'));else boardMsg(x["new"]&&x["new"].length?'ok':'muted',watchCheckMsg(c,x));loadWatchUI();}).catch(function(e){btn.disabled=false;btn.textContent='Check now';boardMsg('err',esc(String((e&&e.message)||e)));});});});
|
|
@@ -982,5 +1054,5 @@ function appBar(p){var b=$('appbar');if(!b||!p.termux||window.SymbiotAndroid||ty
|
|
|
982
1054
|
function doUpdate(){updBusy=true;try{localStorage.setItem('symbiot_update_tried',document.getElementById('ver').textContent.replace(/^v/,''));}catch(e){}var b=ubar();b.className='updatebar show';b.textContent='Updating & restarting… this page will reload itself when it is back.';api('/api/update',{});}
|
|
983
1055
|
setInterval(heartbeat,4000);heartbeat(true);
|
|
984
1056
|
window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
|
|
985
|
-
initGraphEvents();syncP();refresh();loadMap();firstTab();loadAgentCfg();loadScanRoots();loadPhone();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);loadPhoneLink();
|
|
1057
|
+
initGraphEvents();syncP();refresh();loadMap();firstTab();loadAgentCfg();loadScanRoots();loadPhone();loadLinks();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);loadPhoneLink();
|
|
986
1058
|
</script></body></html>`;
|