viberoom 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +3 -0
- package/README.md +7 -4
- package/dist/atomic.js +33 -0
- package/dist/hub.js +3 -4
- package/dist/main.js +14 -9
- package/dist/persona.js +2 -2
- package/dist/room.js +32 -12
- package/dist/server.js +36 -3
- package/dist/viewer.js +66 -0
- package/package.json +2 -1
- package/ui/app.css +35 -4
- package/ui/app.js +369 -31
- package/ui/index.html +12 -0
- package/ui/theme.css +13 -5
package/NOTICE
CHANGED
|
@@ -14,6 +14,9 @@ This product bundles the following third-party material:
|
|
|
14
14
|
- Mermaid (served from node_modules at run time, not copied): MIT License,
|
|
15
15
|
Copyright (c) 2014-2025 Knut Sveidqvist.
|
|
16
16
|
|
|
17
|
+
- marked (served from node_modules at run time, not copied): MIT License,
|
|
18
|
+
Copyright (c) 2018+, MarkedJS; Copyright (c) 2011-2018, Christopher Jeffrey.
|
|
19
|
+
|
|
17
20
|
Everything else is original to this project and licensed under the GNU Affero
|
|
18
21
|
General Public License, version 3 or (at your option) any later version (see
|
|
19
22
|
LICENSE). If you run a modified viberoom as a network service, section 13 of
|
package/README.md
CHANGED
|
@@ -49,13 +49,16 @@ viberoom
|
|
|
49
49
|
a face. Pick the model, the effort and the permission mode per vibemate.
|
|
50
50
|
- **Talk to all, or to one.** Write to the room and everyone answers in turn; `@Name` one of them and
|
|
51
51
|
the others listen. Vibemates read each other's replies and address each other the same way.
|
|
52
|
-
- **Turn taking.**
|
|
53
|
-
cross; or
|
|
52
|
+
- **Turn taking.** Every addressed vibemate answers at once by default, each after a short random
|
|
53
|
+
delay so replies do not cross; or one at a time, the others waiting their turn. A hop limit keeps
|
|
54
|
+
agent-to-agent chatter from running away.
|
|
54
55
|
- **Hush.** One click stops every running reply; the vibemates stay quiet until you write again.
|
|
55
56
|
- **Skills.** Reusable instructions in a library. Attach them to vibemates, invoke one with `/name`,
|
|
56
57
|
or let a vibemate write its own.
|
|
57
|
-
- **
|
|
58
|
-
|
|
58
|
+
- **Markdown, links, files and diagrams.** Replies render as Markdown: lists, tables, code. Links and
|
|
59
|
+
file paths open on your machine; a `.md` or `.csv` path opens right in the room (rendered, or as a
|
|
60
|
+
table); a path with a `:line` opens in your editor at that line; a ```` ```mermaid ```` block
|
|
61
|
+
becomes a diagram.
|
|
59
62
|
- **Edit a message.** Change what you said: the vibemates are told, or the conversation is rewound.
|
|
60
63
|
- **A desktop app.** A hidden hub, an app window of its own, a Start Menu / Dock / desktop icon.
|
|
61
64
|
|
package/dist/atomic.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
import { renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
const RETRY_DELAYS_MS = [10, 30, 60, 120, 250];
|
|
4
|
+
function pause(ms) {
|
|
5
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
6
|
+
}
|
|
7
|
+
function isTransient(error) {
|
|
8
|
+
const code = error?.code;
|
|
9
|
+
return code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
10
|
+
}
|
|
11
|
+
export function writeFileAtomic(path, data, options = {}) {
|
|
12
|
+
const rename = options.rename ?? renameSync;
|
|
13
|
+
const write = options.write ?? writeFileSync;
|
|
14
|
+
const sleep = options.sleep ?? pause;
|
|
15
|
+
const delays = options.retryDelaysMs ?? RETRY_DELAYS_MS;
|
|
16
|
+
const tmp = `${path}.tmp`;
|
|
17
|
+
write(tmp, data);
|
|
18
|
+
for (let attempt = 0;; attempt++) {
|
|
19
|
+
try {
|
|
20
|
+
rename(tmp, path);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (!isTransient(error) || attempt >= delays.length) {
|
|
25
|
+
if (!isTransient(error))
|
|
26
|
+
throw error;
|
|
27
|
+
write(path, data);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
sleep(delays[attempt]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/hub.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, rmSync
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
5
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
5
6
|
import { join, resolve } from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { DEFAULT_EDITOR_SETTINGS } from "./open.js";
|
|
@@ -342,7 +343,5 @@ function slugify(name) {
|
|
|
342
343
|
.slice(0, 40);
|
|
343
344
|
}
|
|
344
345
|
function writeJson(path, value) {
|
|
345
|
-
|
|
346
|
-
writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
347
|
-
renameSync(tmp, path);
|
|
346
|
+
writeFileAtomic(path, JSON.stringify(value, null, 2));
|
|
348
347
|
}
|
package/dist/main.js
CHANGED
|
@@ -234,15 +234,20 @@ async function runHub(options, log, info) {
|
|
|
234
234
|
rmSync(pidFilePath(options.dataDir), { force: true });
|
|
235
235
|
process.exit(0);
|
|
236
236
|
};
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
237
|
+
const listenDeadline = Date.now() + 15_000;
|
|
238
|
+
for (;;) {
|
|
239
|
+
try {
|
|
240
|
+
server = await startServer(hub, options.port, log.child("http"), info, () => void shutdown());
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
const code = error.code;
|
|
245
|
+
if (code !== "EADDRINUSE")
|
|
246
|
+
throw error;
|
|
247
|
+
if (Date.now() > listenDeadline)
|
|
248
|
+
throw new Error(`port ${options.port} is taken by another program (not a viberoom hub). Pick another port: viberoom --port 4811`);
|
|
249
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
244
250
|
}
|
|
245
|
-
throw error;
|
|
246
251
|
}
|
|
247
252
|
hub.setHubUrl(server.url);
|
|
248
253
|
if (background)
|
|
@@ -281,7 +286,7 @@ async function startBackground(options, log, info) {
|
|
|
281
286
|
child.unref();
|
|
282
287
|
closeSync(fd);
|
|
283
288
|
log.info(`hub started in the background (pid ${child.pid}); log: ${logPath}`);
|
|
284
|
-
const up = await waitUntil(() =>
|
|
289
|
+
const up = await waitUntil(async () => (await runningInstance(options.port))?.build === info.build, 20_000);
|
|
285
290
|
if (!up)
|
|
286
291
|
throw new Error(`the hub did not come up within 20 s; see ${logPath}`);
|
|
287
292
|
process.stdout.write(`${url}\n`);
|
package/dist/persona.js
CHANGED
|
@@ -22,7 +22,7 @@ export const DEFAULT_ROOM_SETTINGS = {
|
|
|
22
22
|
emoji: "",
|
|
23
23
|
humanDescriptionMode: "inherit",
|
|
24
24
|
refereeAction: "next-header",
|
|
25
|
-
turnTaking: "
|
|
25
|
+
turnTaking: "parallel",
|
|
26
26
|
replyDelay: 4,
|
|
27
27
|
waitWhileHumanTypes: true,
|
|
28
28
|
};
|
|
@@ -106,7 +106,7 @@ export function buildBrief(settings, persona, roster, previousNotes, skills) {
|
|
|
106
106
|
lines.push(`- Tools: ${tools}`);
|
|
107
107
|
if (settings.maxSentences)
|
|
108
108
|
lines.push(`- Length: at most ${settings.maxSentences} sentences.`);
|
|
109
|
-
lines.push("- Format: plain chat text;
|
|
109
|
+
lines.push("- Format: plain chat text; Markdown is rendered (lists, tables, code, bold), so use it lightly and skip headings. For a diagram, write a ```mermaid block; for tabular data, a Markdown table or a ```csv block: the room renders both. Name files by their absolute path: the human can click them, and .md / .csv files open right in the room.");
|
|
110
110
|
const custom = settings.customRules
|
|
111
111
|
.split(/\r?\n/)
|
|
112
112
|
.map((l) => l.trim().replace(/^[-*•]\s*/, ""))
|
package/dist/room.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync,
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
5
6
|
import { affectedByEdit, editNotice, partitionHistory, rewriteNotice } from "./edit.js";
|
|
6
7
|
import { join, resolve } from "node:path";
|
|
7
8
|
import { AcpAgent } from "./acp-client.js";
|
|
@@ -398,10 +399,7 @@ export class Room extends EventEmitter {
|
|
|
398
399
|
participant.statusDetail = undefined;
|
|
399
400
|
}
|
|
400
401
|
rewriteHistory() {
|
|
401
|
-
|
|
402
|
-
const tmp = `${path}.tmp`;
|
|
403
|
-
writeFileSync(tmp, this.messages.map((m) => JSON.stringify(m)).join("\n") + (this.messages.length ? "\n" : ""));
|
|
404
|
-
renameSync(tmp, path);
|
|
402
|
+
writeFileAtomic(this.historyPath(), this.messages.map((m) => JSON.stringify(m)).join("\n") + (this.messages.length ? "\n" : ""));
|
|
405
403
|
}
|
|
406
404
|
appendDeleted(records, editedSeq) {
|
|
407
405
|
if (!records.length)
|
|
@@ -1497,8 +1495,7 @@ export class Room extends EventEmitter {
|
|
|
1497
1495
|
toolCalls: [],
|
|
1498
1496
|
};
|
|
1499
1497
|
this.drafts.set(draft.id, draft);
|
|
1500
|
-
runtime.turn = { message: draft, messageId: null, sawMessageId: false, startedAt: Date.now() };
|
|
1501
|
-
this.push({ type: "message", message: draft });
|
|
1498
|
+
runtime.turn = { message: draft, messageId: null, sawMessageId: false, startedAt: Date.now(), published: false };
|
|
1502
1499
|
let result = null;
|
|
1503
1500
|
let failure = null;
|
|
1504
1501
|
try {
|
|
@@ -1508,6 +1505,7 @@ export class Room extends EventEmitter {
|
|
|
1508
1505
|
failure = error instanceof Error ? error.message : String(error);
|
|
1509
1506
|
}
|
|
1510
1507
|
const startedAt = runtime.turn.startedAt;
|
|
1508
|
+
const published = runtime.turn.published;
|
|
1511
1509
|
runtime.turn = null;
|
|
1512
1510
|
runtime.turnActive = false;
|
|
1513
1511
|
this.drafts.delete(draft.id);
|
|
@@ -1537,9 +1535,9 @@ export class Room extends EventEmitter {
|
|
|
1537
1535
|
this.closeRetry(retry, "the correction turn failed; nothing was posted");
|
|
1538
1536
|
return null;
|
|
1539
1537
|
}
|
|
1540
|
-
return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry);
|
|
1538
|
+
return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry, published);
|
|
1541
1539
|
}
|
|
1542
|
-
finalizeTurn(participant, runtime, draft, result, durationMs, retry) {
|
|
1540
|
+
finalizeTurn(participant, runtime, draft, result, durationMs, retry, published) {
|
|
1543
1541
|
participant.status = "idle";
|
|
1544
1542
|
this.push({ type: "participant", participant });
|
|
1545
1543
|
const text = draft.text.trim();
|
|
@@ -1565,7 +1563,8 @@ export class Room extends EventEmitter {
|
|
|
1565
1563
|
return this.handleSkillPull(participant, runtime, text, pull[1]);
|
|
1566
1564
|
}
|
|
1567
1565
|
if (!text || text.toLowerCase() === SILENT_MARKER) {
|
|
1568
|
-
|
|
1566
|
+
if (published)
|
|
1567
|
+
this.push({ type: "message.removed", id: draft.id });
|
|
1569
1568
|
if (retry) {
|
|
1570
1569
|
this.closeRetry(retry, cancelled ? "the correction turn was stopped; nothing was posted" : "the agent withdrew the reply");
|
|
1571
1570
|
if (cancelled)
|
|
@@ -1710,6 +1709,12 @@ export class Room extends EventEmitter {
|
|
|
1710
1709
|
}
|
|
1711
1710
|
return corrections;
|
|
1712
1711
|
}
|
|
1712
|
+
showDraft(turn) {
|
|
1713
|
+
if (turn.published)
|
|
1714
|
+
return;
|
|
1715
|
+
turn.published = true;
|
|
1716
|
+
this.push({ type: "message", message: turn.message });
|
|
1717
|
+
}
|
|
1713
1718
|
onSessionUpdate(id, update) {
|
|
1714
1719
|
const runtime = this.runtimes.get(id);
|
|
1715
1720
|
const participant = this.participants.get(id);
|
|
@@ -1727,7 +1732,8 @@ export class Room extends EventEmitter {
|
|
|
1727
1732
|
const notices = (turn.message.notices ??= []);
|
|
1728
1733
|
notices.push(turn.message.text.trim());
|
|
1729
1734
|
turn.message.text = "";
|
|
1730
|
-
|
|
1735
|
+
if (turn.published)
|
|
1736
|
+
this.push({ type: "message", message: turn.message });
|
|
1731
1737
|
}
|
|
1732
1738
|
else if (messageId !== turn.messageId && turn.message.text) {
|
|
1733
1739
|
text = "\n\n" + text;
|
|
@@ -1736,6 +1742,11 @@ export class Room extends EventEmitter {
|
|
|
1736
1742
|
turn.sawMessageId = true;
|
|
1737
1743
|
turn.messageId = messageId;
|
|
1738
1744
|
turn.message.text += text;
|
|
1745
|
+
if (!turn.published) {
|
|
1746
|
+
if (!looksSilent(turn.message.text))
|
|
1747
|
+
this.showDraft(turn);
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1739
1750
|
this.push({ type: "chunk", id: turn.message.id, text });
|
|
1740
1751
|
return;
|
|
1741
1752
|
}
|
|
@@ -1744,13 +1755,15 @@ export class Room extends EventEmitter {
|
|
|
1744
1755
|
return;
|
|
1745
1756
|
const text = contentText(update.content);
|
|
1746
1757
|
turn.message.thought = (turn.message.thought ?? "") + text;
|
|
1747
|
-
|
|
1758
|
+
if (turn.published)
|
|
1759
|
+
this.push({ type: "thought", id: turn.message.id, text });
|
|
1748
1760
|
return;
|
|
1749
1761
|
}
|
|
1750
1762
|
case "tool_call":
|
|
1751
1763
|
case "tool_call_update": {
|
|
1752
1764
|
if (!turn)
|
|
1753
1765
|
return;
|
|
1766
|
+
this.showDraft(turn);
|
|
1754
1767
|
const u = update;
|
|
1755
1768
|
const calls = (turn.message.toolCalls ??= []);
|
|
1756
1769
|
let view = calls.find((c) => c.toolCallId === u.toolCallId);
|
|
@@ -1772,6 +1785,7 @@ export class Room extends EventEmitter {
|
|
|
1772
1785
|
case "plan": {
|
|
1773
1786
|
if (!turn)
|
|
1774
1787
|
return;
|
|
1788
|
+
this.showDraft(turn);
|
|
1775
1789
|
const entries = update.entries;
|
|
1776
1790
|
turn.message.plan = entries;
|
|
1777
1791
|
this.push({ type: "plan", id: turn.message.id, entries });
|
|
@@ -1831,6 +1845,8 @@ export class Room extends EventEmitter {
|
|
|
1831
1845
|
};
|
|
1832
1846
|
this.permissions.set(entry.key, entry);
|
|
1833
1847
|
const { resolve: _r, ...view } = entry;
|
|
1848
|
+
if (runtime?.turn)
|
|
1849
|
+
this.showDraft(runtime.turn);
|
|
1834
1850
|
this.push({ type: "permission", permission: view });
|
|
1835
1851
|
this.notice(`${participant?.name ?? id} asks for permission: ${params.toolCall.title ?? params.toolCall.toolCallId}`, "info");
|
|
1836
1852
|
});
|
|
@@ -2081,6 +2097,10 @@ function isAuthRequired(error) {
|
|
|
2081
2097
|
function describeError(error) {
|
|
2082
2098
|
return error instanceof Error ? error.message : String(error);
|
|
2083
2099
|
}
|
|
2100
|
+
function looksSilent(text) {
|
|
2101
|
+
const t = text.trim().toLowerCase();
|
|
2102
|
+
return t.length <= SILENT_MARKER.length && SILENT_MARKER.startsWith(t);
|
|
2103
|
+
}
|
|
2084
2104
|
function contentText(block) {
|
|
2085
2105
|
if (block.type === "text")
|
|
2086
2106
|
return block.text;
|
package/dist/server.js
CHANGED
|
@@ -3,8 +3,11 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { readFile, stat } from "node:fs/promises";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
6
8
|
import { existsSync as fileExists } from "node:fs";
|
|
7
9
|
import { classifyOpenTarget, describeOpen, detectEditor, editorCommand, isExecutablePath, openCommand } from "./open.js";
|
|
10
|
+
import { parseCsv, viewerKind, VIEWER_MAX_BYTES } from "./viewer.js";
|
|
8
11
|
let editorFound;
|
|
9
12
|
function currentEditor() {
|
|
10
13
|
if (editorFound === undefined)
|
|
@@ -32,12 +35,19 @@ const STATIC_FILES = {
|
|
|
32
35
|
"/vendor-icons/opencode.svg": { file: "vendors/opencode.svg", type: "image/svg+xml", dir: "assets" },
|
|
33
36
|
"/vendor-icons/copilot.svg": { file: "vendors/copilot.svg", type: "image/svg+xml", dir: "assets" },
|
|
34
37
|
"/vendor/mermaid.min.js": { file: "mermaid/dist/mermaid.min.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
|
|
38
|
+
"/vendor/marked.umd.js": { file: "marked/lib/marked.umd.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
|
|
35
39
|
};
|
|
36
40
|
export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
37
41
|
const uiDir = fileURLToPath(new URL("../ui/", import.meta.url));
|
|
38
42
|
const assetsDir = fileURLToPath(new URL("../assets/", import.meta.url));
|
|
39
|
-
const
|
|
40
|
-
const
|
|
43
|
+
const resolveModule = createRequire(import.meta.url).resolve;
|
|
44
|
+
const packageDir = (name) => dirname(resolveModule(`${name}/package.json`));
|
|
45
|
+
const staticPath = (entry) => {
|
|
46
|
+
if (entry.dir !== "node_modules")
|
|
47
|
+
return (entry.dir === "assets" ? assetsDir : uiDir) + entry.file;
|
|
48
|
+
const slash = entry.file.indexOf("/");
|
|
49
|
+
return join(packageDir(entry.file.slice(0, slash)), entry.file.slice(slash + 1));
|
|
50
|
+
};
|
|
41
51
|
const clients = new Set();
|
|
42
52
|
const snapshot = () => ({ ...hub.snapshot(), version: info });
|
|
43
53
|
const broadcast = (event) => {
|
|
@@ -80,7 +90,7 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
80
90
|
}
|
|
81
91
|
if (req.method === "GET" && STATIC_FILES[path]) {
|
|
82
92
|
const entry = STATIC_FILES[path];
|
|
83
|
-
const body = await readFile(
|
|
93
|
+
const body = await readFile(staticPath(entry));
|
|
84
94
|
res.writeHead(200, { "Content-Type": entry.type, "Cache-Control": entry.dir === "ui" || !entry.dir ? "no-cache" : "public, max-age=3600" });
|
|
85
95
|
res.end(body);
|
|
86
96
|
return;
|
|
@@ -96,6 +106,29 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
96
106
|
req.on("close", () => clients.delete(res));
|
|
97
107
|
return;
|
|
98
108
|
}
|
|
109
|
+
if (req.method === "GET" && path === "/api/file") {
|
|
110
|
+
const target = classifyOpenTarget(url.searchParams.get("path") ?? "");
|
|
111
|
+
if (!target || target.kind !== "path")
|
|
112
|
+
throw new Error("only absolute paths can be viewed");
|
|
113
|
+
const kind = viewerKind(target.value);
|
|
114
|
+
if (!kind)
|
|
115
|
+
throw new Error("only Markdown and CSV files can be viewed in the room");
|
|
116
|
+
let info;
|
|
117
|
+
try {
|
|
118
|
+
info = await stat(target.value);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
sendJson(res, 404, { error: `no such file: ${target.value}` });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!info.isFile())
|
|
125
|
+
throw new Error(`not a file: ${target.value}`);
|
|
126
|
+
if (info.size > VIEWER_MAX_BYTES)
|
|
127
|
+
throw new Error(`too big to view here (${Math.round(info.size / 1024)} kB); open it in an editor`);
|
|
128
|
+
const text = await readFile(target.value, "utf8");
|
|
129
|
+
sendJson(res, 200, kind === "csv" ? { ok: true, kind, path: target.value, rows: parseCsv(text) } : { ok: true, kind, path: target.value, text });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
99
132
|
if (req.method === "GET" && path === "/api/editor") {
|
|
100
133
|
sendJson(res, 200, { editor: currentEditor(), settings: hub.settings.editor });
|
|
101
134
|
return;
|
package/dist/viewer.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
export const VIEWER_MAX_BYTES = 2 * 1024 * 1024;
|
|
4
|
+
const KINDS = { ".md": "markdown", ".markdown": "markdown", ".csv": "csv", ".tsv": "csv" };
|
|
5
|
+
export function viewerKind(path) {
|
|
6
|
+
return KINDS[extname(path).toLowerCase()] ?? null;
|
|
7
|
+
}
|
|
8
|
+
export function detectDelimiter(text) {
|
|
9
|
+
const first = text.split(/\r?\n/, 1)[0] ?? "";
|
|
10
|
+
let best = ",";
|
|
11
|
+
let bestCount = -1;
|
|
12
|
+
for (const d of [",", ";", "\t"]) {
|
|
13
|
+
const n = first.split(d).length - 1;
|
|
14
|
+
if (n > bestCount) {
|
|
15
|
+
best = d;
|
|
16
|
+
bestCount = n;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return best;
|
|
20
|
+
}
|
|
21
|
+
export function parseCsv(text, delimiter = detectDelimiter(text)) {
|
|
22
|
+
const rows = [];
|
|
23
|
+
let row = [];
|
|
24
|
+
let field = "";
|
|
25
|
+
let quoted = false;
|
|
26
|
+
const src = text.startsWith("") ? text.slice(1) : text;
|
|
27
|
+
for (let i = 0; i < src.length; i++) {
|
|
28
|
+
const c = src[i];
|
|
29
|
+
if (quoted) {
|
|
30
|
+
if (c === '"') {
|
|
31
|
+
if (src[i + 1] === '"') {
|
|
32
|
+
field += '"';
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
else
|
|
36
|
+
quoted = false;
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
field += c;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (c === '"' && field === "")
|
|
43
|
+
quoted = true;
|
|
44
|
+
else if (c === delimiter) {
|
|
45
|
+
row.push(field);
|
|
46
|
+
field = "";
|
|
47
|
+
}
|
|
48
|
+
else if (c === "\n" || c === "\r") {
|
|
49
|
+
if (c === "\r" && src[i + 1] === "\n")
|
|
50
|
+
i++;
|
|
51
|
+
row.push(field);
|
|
52
|
+
rows.push(row);
|
|
53
|
+
row = [];
|
|
54
|
+
field = "";
|
|
55
|
+
}
|
|
56
|
+
else
|
|
57
|
+
field += c;
|
|
58
|
+
}
|
|
59
|
+
if (field !== "" || row.length) {
|
|
60
|
+
row.push(field);
|
|
61
|
+
rows.push(row);
|
|
62
|
+
}
|
|
63
|
+
while (rows.length && rows[rows.length - 1].every((f) => f === ""))
|
|
64
|
+
rows.pop();
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viberoom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "viberoom: group chat rooms for a human and several coding agents over the Agent Client Protocol",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@agentclientprotocol/claude-agent-acp": "0.73.0",
|
|
32
32
|
"@agentclientprotocol/codex-acp": "^1.8.0",
|
|
33
|
+
"marked": "^16.4.2",
|
|
33
34
|
"mermaid": "^11.17.2"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
package/ui/app.css
CHANGED
|
@@ -200,7 +200,38 @@
|
|
|
200
200
|
.head .time { color: var(--faint); font-size: 11px; font-weight: 600; cursor: default; white-space: nowrap; }
|
|
201
201
|
.msg.mine .head { justify-content: flex-end; }
|
|
202
202
|
.msg.mine .head .name { display: none; }
|
|
203
|
-
.bubble .text {
|
|
203
|
+
.bubble .text { word-break: break-word; line-height: 1.55; }
|
|
204
|
+
.text > :first-child, .file-view > :first-child { margin-top: 0; }
|
|
205
|
+
.text > :last-child, .file-view > :last-child { margin-bottom: 0; }
|
|
206
|
+
.text p, .file-view p { margin: 0 0 8px; }
|
|
207
|
+
.text ul, .text ol, .file-view ul, .file-view ol { margin: 4px 0 8px; padding-left: 22px; }
|
|
208
|
+
.text li, .file-view li { margin: 2px 0; }
|
|
209
|
+
.text h1, .text h2, .text h3, .text h4, .file-view h1, .file-view h2, .file-view h3, .file-view h4 { margin: 10px 0 6px; line-height: 1.3; font-weight: 800; }
|
|
210
|
+
.text h1, .file-view h1 { font-size: 1.25em; }
|
|
211
|
+
.text h2, .file-view h2 { font-size: 1.15em; }
|
|
212
|
+
.text h3, .text h4, .file-view h3, .file-view h4 { font-size: 1.05em; }
|
|
213
|
+
.text blockquote, .file-view blockquote { margin: 6px 0 8px; padding: 2px 12px; border-left: 3px solid var(--lav); color: var(--muted); }
|
|
214
|
+
.text hr, .file-view hr { border: 0; border-top: 1px solid var(--border); margin: 10px 0; }
|
|
215
|
+
.text pre, .file-view pre { margin: 6px 0 8px; }
|
|
216
|
+
.text table, .file-view table { border-collapse: collapse; margin: 6px 0 8px; font-size: 13px; max-width: 100%; display: block; overflow-x: auto; }
|
|
217
|
+
.text th, .text td, .file-view th, .file-view td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; vertical-align: top; }
|
|
218
|
+
.text th, .file-view th { background: var(--lav); font-weight: 800; }
|
|
219
|
+
.msg.mine .bubble .text th { background: rgba(255, 255, 255, 0.18); }
|
|
220
|
+
.msg.mine .bubble .text th, .msg.mine .bubble .text td { border-color: rgba(255, 255, 255, 0.35); }
|
|
221
|
+
.msg.mine .bubble .text blockquote { border-color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.85); }
|
|
222
|
+
.file-dialog { width: 900px; flex-direction: column; }
|
|
223
|
+
.file-dialog[open] { display: flex; }
|
|
224
|
+
.file-dialog .file-head { flex: 0 0 auto; }
|
|
225
|
+
.file-dialog .file-head .lead { margin-bottom: 10px; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; }
|
|
226
|
+
.file-dialog .actions { flex: 0 0 auto; margin-top: 14px; }
|
|
227
|
+
.file-view { flex: 1 1 auto; min-height: 120px; max-height: 62vh; overflow: auto; padding: 14px 16px; border-radius: 14px; background: var(--bg); font-size: 14px; font-weight: 600; line-height: 1.55; color: var(--ink-2); }
|
|
228
|
+
.file-view.csv { padding: 0; background: transparent; }
|
|
229
|
+
.csv-wrap { overflow: auto; max-height: 58vh; border-radius: 14px; box-shadow: var(--edge); background: var(--card); }
|
|
230
|
+
table.csv { border-collapse: separate; border-spacing: 0; font-size: 13px; min-width: 100%; }
|
|
231
|
+
table.csv th, table.csv td { padding: 6px 12px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; max-width: 420px; overflow: hidden; text-overflow: ellipsis; }
|
|
232
|
+
table.csv th { position: sticky; top: 0; background: var(--lav); font-weight: 800; z-index: 1; }
|
|
233
|
+
table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
|
|
234
|
+
.csv-count { margin: 8px 2px 0; font-size: 12px; color: var(--muted); font-weight: 700; }
|
|
204
235
|
.bubble .text.clamped { max-height: 9.2em; overflow: hidden; -webkit-mask-image: linear-gradient(180deg, #000 70%, transparent); mask-image: linear-gradient(180deg, #000 70%, transparent); }
|
|
205
236
|
.bubble .more { background: none; border: 0; color: var(--primary); font-weight: 800; padding: 2px 0; font-size: 13px; }
|
|
206
237
|
.msg.mine .bubble .more { color: #fff; }
|
|
@@ -267,7 +298,7 @@
|
|
|
267
298
|
.side-list li.offline .p-name, .side-list li.offline .p-sub { color: var(--muted); }
|
|
268
299
|
.side-list li.me .avatar .av-tile, .msg.mine .avatar .av-tile { box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--primary); }
|
|
269
300
|
.msg.mine .avatar .av-tile { box-shadow: 0 0 0 2px #fff, 0 0 0 3.5px var(--primary); }
|
|
270
|
-
.composer { display: flex; gap: 10px; padding: 8px 8px 8px 18px; margin:
|
|
301
|
+
.composer { display: flex; gap: 10px; padding: 8px 8px 8px 18px; margin: 10px 16px; background: var(--softer); border-radius: 20px; align-items: center; min-height: 60px; transition: box-shadow var(--t-fast); }
|
|
271
302
|
.composer:focus-within { box-shadow: 0 0 0 2px var(--primary); }
|
|
272
303
|
.composer .smile-btn { border: 0; background: transparent; color: var(--placeholder); padding: 0; display: grid; place-content: center; width: 28px; height: 28px; border-radius: 8px; flex: none; }
|
|
273
304
|
.composer .smile-btn .i { width: 20px; height: 20px; }
|
|
@@ -279,8 +310,8 @@
|
|
|
279
310
|
.send-btn .i { width: 18px; height: 18px; display: block; }
|
|
280
311
|
.send-btn:hover { filter: brightness(1.05); transform: translateY(-1px); }
|
|
281
312
|
.send-btn:active { transform: translateY(1px); }
|
|
282
|
-
.chat .mention-menu { left: 24px; bottom:
|
|
283
|
-
.chat .emoji-menu { left: 24px; bottom:
|
|
313
|
+
.chat .mention-menu { left: 24px; bottom: 78px; }
|
|
314
|
+
.chat .emoji-menu { left: 24px; bottom: 78px; }
|
|
284
315
|
.page-inner { flex: 1; overflow-y: auto; padding: 8px 22px 22px; }
|
|
285
316
|
.page-inner > .page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; min-height: 48px; }
|
|
286
317
|
.page-inner h1 { font-size: 20px; letter-spacing: -0.01em; }
|
package/ui/app.js
CHANGED
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
const $ = (selector) => document.querySelector(selector);
|
|
25
25
|
const els = {
|
|
26
26
|
app: $("#app"),
|
|
27
|
+
fileDialog: $("#file-dialog"),
|
|
28
|
+
fvTitle: $("#fv-title"),
|
|
29
|
+
fvPath: $("#fv-path"),
|
|
30
|
+
fvBody: $("#fv-body"),
|
|
31
|
+
fvOpen: $("#fv-open"),
|
|
27
32
|
rail: $("#rail"),
|
|
28
33
|
railRoom: $("#rail-room"),
|
|
29
34
|
railRoomLabel: $("#rail-room-label"),
|
|
@@ -191,7 +196,7 @@
|
|
|
191
196
|
}
|
|
192
197
|
function editingInDetails() {
|
|
193
198
|
const el = document.activeElement;
|
|
194
|
-
return !!el && !!el.closest && (!!el.closest("#details") || !!el.closest("#page-view")) && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName);
|
|
199
|
+
return !!el && !!el.closest && (!!el.closest("#details") || !!el.closest("#page-view")) && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable);
|
|
195
200
|
}
|
|
196
201
|
function currentRoom() {
|
|
197
202
|
return state.rooms.get(state.currentRoomId) || null;
|
|
@@ -212,10 +217,109 @@
|
|
|
212
217
|
return `<a class="open-link" data-open="${target}" href="#" title="${isUrl ? "Open in your browser" : "Open with the default app"}">${target}</a>${trail}`;
|
|
213
218
|
});
|
|
214
219
|
}
|
|
220
|
+
function parseCsv(text) {
|
|
221
|
+
const first = text.split(/\r?\n/, 1)[0] || "";
|
|
222
|
+
let delimiter = ",";
|
|
223
|
+
let best = -1;
|
|
224
|
+
for (const d of [",", ";", "\t"]) {
|
|
225
|
+
const n = first.split(d).length - 1;
|
|
226
|
+
if (n > best) {
|
|
227
|
+
delimiter = d;
|
|
228
|
+
best = n;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const rows = [];
|
|
232
|
+
let row = [];
|
|
233
|
+
let field = "";
|
|
234
|
+
let quoted = false;
|
|
235
|
+
for (let i = 0; i < text.length; i++) {
|
|
236
|
+
const c = text[i];
|
|
237
|
+
if (quoted) {
|
|
238
|
+
if (c === '"') {
|
|
239
|
+
if (text[i + 1] === '"') {
|
|
240
|
+
field += '"';
|
|
241
|
+
i++;
|
|
242
|
+
} else quoted = false;
|
|
243
|
+
} else field += c;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (c === '"' && field === "") quoted = true;
|
|
247
|
+
else if (c === delimiter) {
|
|
248
|
+
row.push(field);
|
|
249
|
+
field = "";
|
|
250
|
+
} else if (c === "\n" || c === "\r") {
|
|
251
|
+
if (c === "\r" && text[i + 1] === "\n") i++;
|
|
252
|
+
row.push(field);
|
|
253
|
+
rows.push(row);
|
|
254
|
+
row = [];
|
|
255
|
+
field = "";
|
|
256
|
+
} else field += c;
|
|
257
|
+
}
|
|
258
|
+
if (field !== "" || row.length) {
|
|
259
|
+
row.push(field);
|
|
260
|
+
rows.push(row);
|
|
261
|
+
}
|
|
262
|
+
while (rows.length && rows[rows.length - 1].every((f) => f === "")) rows.pop();
|
|
263
|
+
return rows;
|
|
264
|
+
}
|
|
265
|
+
function csvBlock(raw) {
|
|
266
|
+
const rows = parseCsv(raw.trim());
|
|
267
|
+
const code = esc(raw.trim());
|
|
268
|
+
if (rows.length < 2) return `<pre>${code}</pre>`;
|
|
269
|
+
return `<div class="csv-block"><div class="mm-out">${csvTable(rows)}</div><pre class="mm-code" hidden>${code}</pre><div class="mm-bar"><button type="button" class="mm-src">source</button></div></div>`;
|
|
270
|
+
}
|
|
215
271
|
function mermaidBlock(code) {
|
|
216
272
|
return `<div class="mermaid-block" data-src="${code}"><div class="mm-out"><pre>${code}</pre></div><pre class="mm-code" hidden>${code}</pre><div class="mm-bar"><button type="button" class="mm-src">source</button></div></div>`;
|
|
217
273
|
}
|
|
274
|
+
function mentions(room, html) {
|
|
275
|
+
return html.replace(/(?<![\w.\/:])@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu, (m, name) => {
|
|
276
|
+
const p = findByName(room, name);
|
|
277
|
+
return p ? `<span class="mention" style="color:${p.color}">@${esc(name)}</span>` : m;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const md = window.marked ? new window.marked.Marked({ gfm: true, breaks: true }) : null;
|
|
281
|
+
if (md) {
|
|
282
|
+
md.use({
|
|
283
|
+
renderer: {
|
|
284
|
+
html(token) {
|
|
285
|
+
return esc(token.text != null ? token.text : token.raw || "");
|
|
286
|
+
},
|
|
287
|
+
code(token) {
|
|
288
|
+
const lang = token.lang || "";
|
|
289
|
+
if (/^\s*(csv|tsv)\b/i.test(lang)) return csvBlock(String(token.text || ""));
|
|
290
|
+
const code = esc(String(token.text || "")).trim();
|
|
291
|
+
return /^\s*mermaid\b/i.test(lang) ? mermaidBlock(code) : `<pre>${code}</pre>`;
|
|
292
|
+
},
|
|
293
|
+
link(token) {
|
|
294
|
+
const inner = this.parser.parseInline(token.tokens || []);
|
|
295
|
+
const href = String(token.href || "");
|
|
296
|
+
if (!/^(https?:|mailto:)/i.test(href)) return inner;
|
|
297
|
+
return `<a class="open-link" data-open="${esc(href)}" href="#" title="Open in your browser">${inner}</a>`;
|
|
298
|
+
},
|
|
299
|
+
image(token) {
|
|
300
|
+
return esc(token.text || token.href || "");
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
function decorate(room, html) {
|
|
306
|
+
let inside = 0;
|
|
307
|
+
let out = "";
|
|
308
|
+
for (const part of html.split(/(<[^>]*>)/)) {
|
|
309
|
+
if (!part) continue;
|
|
310
|
+
if (part[0] === "<") {
|
|
311
|
+
const m = /^<(\/?)(pre|a)\b/i.exec(part);
|
|
312
|
+
if (m) inside += m[1] ? -1 : 1;
|
|
313
|
+
out += part;
|
|
314
|
+
} else out += inside > 0 ? part : mentions(room, linkify(part));
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
218
318
|
function renderText(room, text) {
|
|
319
|
+
if (!md) return renderTextLight(room, text);
|
|
320
|
+
return decorate(room, md.parse(String(text == null ? "" : text)));
|
|
321
|
+
}
|
|
322
|
+
function renderTextLight(room, text) {
|
|
219
323
|
let html = esc(text);
|
|
220
324
|
const blocks = [];
|
|
221
325
|
html = html.replace(/```([^\n]*)\n([\s\S]*?)```/g, (m, lang, code) => {
|
|
@@ -225,15 +329,34 @@
|
|
|
225
329
|
html = linkify(html);
|
|
226
330
|
html = html.replace(/`([^`\n]+)`/g, "<code>$1</code>");
|
|
227
331
|
html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
|
228
|
-
html =
|
|
229
|
-
const p = findByName(room, name);
|
|
230
|
-
return p ? `<span class="mention" style="color:${p.color}">@${esc(name)}</span>` : m;
|
|
231
|
-
});
|
|
332
|
+
html = mentions(room, html);
|
|
232
333
|
html = html.replace(/\u0000(\d+)\u0000/g, (m, i) => blocks[Number(i)]);
|
|
233
334
|
return html;
|
|
234
335
|
}
|
|
235
336
|
|
|
236
337
|
|
|
338
|
+
const VIEWABLE_RE = /\.(md|markdown|csv|tsv)$/i;
|
|
339
|
+
function csvTable(rows) {
|
|
340
|
+
if (!rows.length) return '<p class="lead">Empty file.</p>';
|
|
341
|
+
const cell = (tag, v) => `<${tag}>${esc(v)}</${tag}>`;
|
|
342
|
+
const [head, ...body] = rows;
|
|
343
|
+
const width = Math.max(head.length, ...body.map((r) => r.length));
|
|
344
|
+
const pad = (r) => r.concat(Array(Math.max(0, width - r.length)).fill(""));
|
|
345
|
+
return `<div class="csv-wrap"><table class="csv"><thead><tr>${pad(head).map((v) => cell("th", v)).join("")}</tr></thead><tbody>${body.map((r) => `<tr>${pad(r).map((v) => cell("td", v)).join("")}</tr>`).join("")}</tbody></table></div><p class="csv-count">${body.length} row${body.length === 1 ? "" : "s"} · ${width} column${width === 1 ? "" : "s"}</p>`;
|
|
346
|
+
}
|
|
347
|
+
async function viewFile(path) {
|
|
348
|
+
const r = await get(`/api/file?path=${encodeURIComponent(path)}`);
|
|
349
|
+
els.fvTitle.textContent = r.path.split(/[\\/]/).pop();
|
|
350
|
+
els.fvPath.textContent = r.path;
|
|
351
|
+
els.fvBody.className = `file-view ${r.kind}`;
|
|
352
|
+
els.fvBody.innerHTML = r.kind === "csv" ? csvTable(r.rows) : renderText(currentRoom(), r.text);
|
|
353
|
+
els.fvOpen.dataset.path = r.path;
|
|
354
|
+
els.fvBody.scrollTop = 0;
|
|
355
|
+
openDialog(els.fileDialog);
|
|
356
|
+
if (r.kind === "markdown") renderDiagrams(els.fvBody);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
237
360
|
const POP_PALETTE = [
|
|
238
361
|
{ fill: "#e4e6fb", stroke: "#5b5bf0" },
|
|
239
362
|
{ fill: "#d9f7e8", stroke: "#1d8f6a" },
|
|
@@ -812,21 +935,21 @@
|
|
|
812
935
|
els.sideRoomName.textContent = room.name;
|
|
813
936
|
els.sideRoomEmoji.textContent = room.settings.emoji || "";
|
|
814
937
|
els.sideRoomSub.textContent = room.settings.topic || `${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}${st.agents.length ? ` · ${st.online} online` : ""}`;
|
|
815
|
-
els.participants.innerHTML = "";
|
|
816
938
|
const ordered = [...room.participants].sort((a, b) => (a.kind === "human" ? -1 : b.kind === "human" ? 1 : 0));
|
|
939
|
+
const rows = new Map([...els.participants.children].map((li) => [li.dataset.id, li]));
|
|
817
940
|
for (const p of ordered) {
|
|
818
|
-
|
|
941
|
+
let li = rows.get(p.id);
|
|
819
942
|
const selected = (state.selection.kind === "participant" && state.selection.id === p.id) || (p.kind === "human" && state.selection.kind === "me" && state.detailsOpen);
|
|
820
943
|
const asleep = p.kind === "agent" && (p.status === "offline" || p.status === "left");
|
|
821
|
-
|
|
944
|
+
const className = (p.kind === "human" ? "me" : "") + (selected ? " selected" : "") + (asleep ? " offline" : "");
|
|
822
945
|
const sub = p.kind === "human" ? "you, the human" : [p.tagline ? `"${p.tagline}"` : "", p.agentVendor || p.agentLabel, p.model].filter(Boolean).join(" · ");
|
|
823
946
|
const warn = p.statusDetail && (p.status === "offline" || p.status === "error" || p.failedTurns) ? `<div class="p-warn" title="${esc(p.statusDetail)}">${esc(p.statusDetail)}</div>` : "";
|
|
824
947
|
const status = asleep
|
|
825
948
|
? `<span class="zzz" title="${esc(STATUS_LABEL[p.status] || p.status)}">zzz</span>`
|
|
826
949
|
: p.kind === "agent" && p.status !== "idle" ? `<span class="badge status-${p.status}">${p.status === "thinking" ? '<span class="dot"></span>' : ""}${STATUS_LABEL[p.status] || p.status}</span>` : "";
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
950
|
+
const avatarHtml = avatar(p.kind === "human" ? meAvatarData() : p, 44, { vendor: true });
|
|
951
|
+
const statusClass = p.kind === "agent" ? `avatar-status status-${esc(p.status || "idle")}` : "";
|
|
952
|
+
const bodyHtml = `<div class="p-body">
|
|
830
953
|
<div class="p-name"><span>${esc(p.name)}</span>${p.muted ? '<span class="badge muted">muted</span>' : ""}${status}</div>
|
|
831
954
|
<div class="p-sub">${esc(sub)}</div>
|
|
832
955
|
${warn}
|
|
@@ -835,20 +958,32 @@
|
|
|
835
958
|
${p.kind === "agent" && p.status === "thinking" ? `<button class="icon-btn sm stop-btn" title="Stop this reply">${ic("stop")}</button>` : ""}
|
|
836
959
|
${p.kind === "agent" && p.status === "offline" ? `<button class="icon-btn sm reconnect-btn" title="Reconnect">${ic("refresh")}</button>` : ""}
|
|
837
960
|
</div>`;
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
961
|
+
if (!li) {
|
|
962
|
+
li = document.createElement("li");
|
|
963
|
+
li.dataset.id = p.id;
|
|
964
|
+
li.innerHTML = avatarHtml + bodyHtml;
|
|
965
|
+
li.dataset.avatar = avatarHtml;
|
|
966
|
+
if (statusClass) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="${statusClass}"></span>`);
|
|
967
|
+
li.dataset.body = bodyHtml;
|
|
968
|
+
} else {
|
|
969
|
+
if (li.dataset.avatar !== avatarHtml) {
|
|
970
|
+
li.querySelector(".avatar").outerHTML = avatarHtml;
|
|
971
|
+
li.dataset.avatar = avatarHtml;
|
|
972
|
+
}
|
|
973
|
+
const dot = li.querySelector(".avatar-status");
|
|
974
|
+
if (statusClass && dot && dot.className !== statusClass) dot.className = statusClass;
|
|
975
|
+
else if (statusClass && !dot) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="${statusClass}"></span>`);
|
|
976
|
+
if (li.dataset.body !== bodyHtml) {
|
|
977
|
+
li.querySelectorAll(".p-body, .p-actions").forEach((el) => el.remove());
|
|
978
|
+
li.insertAdjacentHTML("beforeend", bodyHtml);
|
|
979
|
+
li.dataset.body = bodyHtml;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (li.className !== className) li.className = className;
|
|
983
|
+
if (li !== els.participants.children[ordered.indexOf(p)]) els.participants.appendChild(li);
|
|
984
|
+
rows.delete(p.id);
|
|
851
985
|
}
|
|
986
|
+
for (const li of rows.values()) li.remove();
|
|
852
987
|
renderHushButton(room);
|
|
853
988
|
els.reconnectAllBtn.hidden = offlineAgents(room).length === 0;
|
|
854
989
|
}
|
|
@@ -1546,7 +1681,7 @@
|
|
|
1546
1681
|
${field("Emoji", `<div id="rp-emoji-picker"></div><input type="text" id="rp-emoji" maxlength="8" value="${esc(rs.emoji || "")}" placeholder="custom emoji (optional)">`, "A face for the room, next to its name.")}
|
|
1547
1682
|
${field("Topic", `<input type="text" id="rp-topic" maxlength="2000" value="${esc(rs.topic || "")}" placeholder="what this room is about (optional)">`)}
|
|
1548
1683
|
${field("Folder", `<input type="text" id="rp-dir" maxlength="1000" value="${esc(room.dir)}" spellcheck="false">`, "Where the vibemates read and write. Changing it restarts them in the new folder; they replay the last messages.")}
|
|
1549
|
-
<label class="field mention-host"><span class="label">Custom rules${geekTip("References follow renames and note when a participant has left. Rules go into every vibemate's brief as instructions, not as routing.")}</span><
|
|
1684
|
+
<label class="field mention-host"><span class="label">Custom rules${geekTip("References follow renames and note when a participant has left. Rules go into every vibemate's brief as instructions, not as routing.")}</span><div id="rp-rules" class="rules-editor" contenteditable="true" spellcheck="true" data-placeholder="e.g. Everyone listens to @Pesho, he is the manager. Keep answers under 3 sentences."></div><span class="hint">One rule per line; type @ to reference a participant.</span><div class="mention-menu inline" id="rp-rules-menu" hidden></div></label>
|
|
1550
1685
|
${field("Language", `<input type="text" id="rp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
|
|
1551
1686
|
</div>
|
|
1552
1687
|
<div class="section">
|
|
@@ -1593,7 +1728,8 @@
|
|
|
1593
1728
|
<div class="row-btns start"><button class="btn danger sm" id="rp-delete">${ic("trash")}Close this room for good</button></div>
|
|
1594
1729
|
</div>`;
|
|
1595
1730
|
wireDetailsClose();
|
|
1596
|
-
|
|
1731
|
+
rulesToNodes($("#rp-rules"), room.customRulesText != null ? room.customRulesText : rs.customRules || "", room);
|
|
1732
|
+
attachRichMentions($("#rp-rules"), $("#rp-rules-menu"));
|
|
1597
1733
|
$("#rp-emoji-picker").appendChild(
|
|
1598
1734
|
emojiGrid(ROOM_EMOJI, rs.emoji || "", (emoji) => {
|
|
1599
1735
|
$("#rp-emoji").value = emoji;
|
|
@@ -1608,7 +1744,7 @@
|
|
|
1608
1744
|
await post(roomApi("/settings"), {
|
|
1609
1745
|
emoji: $("#rp-emoji").value,
|
|
1610
1746
|
topic: $("#rp-topic").value,
|
|
1611
|
-
customRules: $("#rp-rules").
|
|
1747
|
+
customRules: rulesText($("#rp-rules")).slice(0, 4000),
|
|
1612
1748
|
language: $("#rp-lang").value.trim() || "follow-human",
|
|
1613
1749
|
tools: $("#rp-tools").value,
|
|
1614
1750
|
maxSentences: $("#rp-maxlen").value === "" ? null : Number($("#rp-maxlen").value),
|
|
@@ -2584,20 +2720,209 @@
|
|
|
2584
2720
|
typingSentAt = now;
|
|
2585
2721
|
post(roomApi("/typing"), {}).catch(() => undefined);
|
|
2586
2722
|
});
|
|
2723
|
+
els.participants.addEventListener("click", (e) => {
|
|
2724
|
+
const li = e.target.closest("li[data-id]");
|
|
2725
|
+
const room = currentRoom();
|
|
2726
|
+
if (!li || !room) return;
|
|
2727
|
+
const p = findById(room, li.dataset.id);
|
|
2728
|
+
if (!p) return;
|
|
2729
|
+
if (e.target.closest(".stop-btn")) return void post(roomApi(`/participants/${encodeURIComponent(p.id)}/cancel`)).catch(showError);
|
|
2730
|
+
if (e.target.closest(".reconnect-btn")) return openReconnectDialog(room);
|
|
2731
|
+
if (e.target.closest("button")) return;
|
|
2732
|
+
if (p.kind === "human") openDetails({ kind: "me" });
|
|
2733
|
+
else openDetails({ kind: "participant", id: p.id });
|
|
2734
|
+
});
|
|
2735
|
+
els.participants.addEventListener("dblclick", (e) => {
|
|
2736
|
+
const li = e.target.closest("li[data-id]");
|
|
2737
|
+
const p = li && findById(currentRoom(), li.dataset.id);
|
|
2738
|
+
if (p && p.kind === "agent") insertMention(p.name);
|
|
2739
|
+
});
|
|
2587
2740
|
els.composer.addEventListener("submit", async (event) => {
|
|
2588
2741
|
event.preventDefault();
|
|
2589
2742
|
const text = els.input.value.trim();
|
|
2590
|
-
|
|
2743
|
+
const room = currentRoom();
|
|
2744
|
+
if (!text || !room) return;
|
|
2591
2745
|
els.input.value = "";
|
|
2592
2746
|
typingSentAt = 0;
|
|
2593
2747
|
autosize();
|
|
2748
|
+
const local = { id: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, seq: 0, from: "human", fromName: (state.settings || {}).humanName || "You", to: [], toNames: [], text, ts: Date.now(), kind: "chat", pending: true };
|
|
2749
|
+
upsertMessage(room.id, local);
|
|
2594
2750
|
try {
|
|
2595
|
-
await post(roomApi("/send"), { text });
|
|
2751
|
+
const r = await post(roomApi("/send"), { text });
|
|
2752
|
+
adoptLocalMessage(room.id, local.id, r.id);
|
|
2596
2753
|
} catch (error) {
|
|
2754
|
+
removeMessage(room.id, local.id);
|
|
2597
2755
|
showError(error);
|
|
2598
2756
|
els.input.value = text;
|
|
2599
2757
|
}
|
|
2600
2758
|
});
|
|
2759
|
+
function adoptLocalMessage(roomId, localId, realId) {
|
|
2760
|
+
const room = state.rooms.get(roomId);
|
|
2761
|
+
if (!room || !realId) return;
|
|
2762
|
+
if (room.messages.some((m) => m.id === realId)) return removeMessage(roomId, localId);
|
|
2763
|
+
const m = room.messages.find((x) => x.id === localId);
|
|
2764
|
+
if (!m) return;
|
|
2765
|
+
m.id = realId;
|
|
2766
|
+
delete m.pending;
|
|
2767
|
+
const el = els.messages.querySelector(`.msg[data-id="${localId}"]`);
|
|
2768
|
+
if (el) el.dataset.id = realId;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
|
|
2772
|
+
const RULE_MENTION_RE = /(?<![\w.\/:])@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu;
|
|
2773
|
+
function mentionChip(p) {
|
|
2774
|
+
const chip = document.createElement("span");
|
|
2775
|
+
chip.className = "mention-chip";
|
|
2776
|
+
chip.contentEditable = "false";
|
|
2777
|
+
chip.dataset.name = p.name;
|
|
2778
|
+
chip.style.color = p.color;
|
|
2779
|
+
chip.innerHTML = `${avatar(p, 16, { vendor: false })}<span class="chip-name">@${esc(p.name)}</span>`;
|
|
2780
|
+
return chip;
|
|
2781
|
+
}
|
|
2782
|
+
function rulesToNodes(editor, text, room) {
|
|
2783
|
+
editor.innerHTML = "";
|
|
2784
|
+
let last = 0;
|
|
2785
|
+
for (const m of text.matchAll(RULE_MENTION_RE)) {
|
|
2786
|
+
const p = findByName(room, m[1]);
|
|
2787
|
+
if (!p) continue;
|
|
2788
|
+
editor.appendChild(document.createTextNode(text.slice(last, m.index)));
|
|
2789
|
+
editor.appendChild(mentionChip(p));
|
|
2790
|
+
last = m.index + m[0].length;
|
|
2791
|
+
}
|
|
2792
|
+
editor.appendChild(document.createTextNode(text.slice(last)));
|
|
2793
|
+
}
|
|
2794
|
+
function rulesText(editor) {
|
|
2795
|
+
let out = "";
|
|
2796
|
+
const walk = (node) => {
|
|
2797
|
+
for (const n of node.childNodes) {
|
|
2798
|
+
if (n.nodeType === Node.TEXT_NODE) out += n.nodeValue;
|
|
2799
|
+
else if (n.nodeName === "BR") out += "\n";
|
|
2800
|
+
else if (n.classList && n.classList.contains("mention-chip")) out += `@${n.dataset.name}`;
|
|
2801
|
+
else if (n.nodeName === "DIV" || n.nodeName === "P") {
|
|
2802
|
+
if (out && !out.endsWith("\n")) out += "\n";
|
|
2803
|
+
walk(n);
|
|
2804
|
+
if (!out.endsWith("\n")) out += "\n";
|
|
2805
|
+
} else walk(n);
|
|
2806
|
+
}
|
|
2807
|
+
};
|
|
2808
|
+
walk(editor);
|
|
2809
|
+
return out.replace(/\n+$/, "");
|
|
2810
|
+
}
|
|
2811
|
+
function attachRichMentions(editor, menuEl) {
|
|
2812
|
+
const m = { open: false, items: [], index: 0, node: null, start: -1 };
|
|
2813
|
+
function caret() {
|
|
2814
|
+
const sel = window.getSelection();
|
|
2815
|
+
if (!sel || !sel.rangeCount || !sel.isCollapsed) return null;
|
|
2816
|
+
const r = sel.getRangeAt(0);
|
|
2817
|
+
if (r.startContainer.nodeType !== Node.TEXT_NODE || !editor.contains(r.startContainer)) return null;
|
|
2818
|
+
return { node: r.startContainer, offset: r.startOffset };
|
|
2819
|
+
}
|
|
2820
|
+
function placeCaret(node, offset) {
|
|
2821
|
+
const sel = window.getSelection();
|
|
2822
|
+
const r = document.createRange();
|
|
2823
|
+
r.setStart(node, offset);
|
|
2824
|
+
r.collapse(true);
|
|
2825
|
+
sel.removeAllRanges();
|
|
2826
|
+
sel.addRange(r);
|
|
2827
|
+
}
|
|
2828
|
+
function context() {
|
|
2829
|
+
const c = caret();
|
|
2830
|
+
if (!c) return null;
|
|
2831
|
+
const before = c.node.nodeValue.slice(0, c.offset);
|
|
2832
|
+
const match = before.match(/(^|\s)@([\p{L}\p{N}_-]*)$/u);
|
|
2833
|
+
if (!match) return null;
|
|
2834
|
+
return { node: c.node, start: c.offset - match[2].length - 1, end: c.offset, prefix: match[2] };
|
|
2835
|
+
}
|
|
2836
|
+
function close() {
|
|
2837
|
+
if (!m.open) return;
|
|
2838
|
+
m.open = false;
|
|
2839
|
+
menuEl.hidden = true;
|
|
2840
|
+
}
|
|
2841
|
+
function render() {
|
|
2842
|
+
const ctx = context();
|
|
2843
|
+
const room = currentRoom();
|
|
2844
|
+
if (!ctx || !room) return close();
|
|
2845
|
+
const q = ctx.prefix.toLowerCase();
|
|
2846
|
+
const items = room.participants.filter((p) => p.name.toLowerCase().startsWith(q));
|
|
2847
|
+
if (!items.length) return close();
|
|
2848
|
+
m.open = true;
|
|
2849
|
+
m.items = items;
|
|
2850
|
+
m.node = ctx.node;
|
|
2851
|
+
m.start = ctx.start;
|
|
2852
|
+
m.end = ctx.end;
|
|
2853
|
+
if (m.index >= items.length) m.index = 0;
|
|
2854
|
+
menuEl.innerHTML = "";
|
|
2855
|
+
items.forEach((p, i) => {
|
|
2856
|
+
const b = document.createElement("button");
|
|
2857
|
+
b.type = "button";
|
|
2858
|
+
b.className = i === m.index ? "active" : "";
|
|
2859
|
+
b.innerHTML = `${avatar(p, 24, { vendor: true })}<span>${esc(p.name)}</span><span class="mm-sub">${esc(p.kind === "human" ? "you" : p.tagline || p.agentVendor || "")}${p.status === "offline" ? " · offline" : ""}</span>`;
|
|
2860
|
+
b.addEventListener("mousedown", (e) => {
|
|
2861
|
+
e.preventDefault();
|
|
2862
|
+
pick(i);
|
|
2863
|
+
});
|
|
2864
|
+
menuEl.appendChild(b);
|
|
2865
|
+
});
|
|
2866
|
+
menuEl.hidden = false;
|
|
2867
|
+
}
|
|
2868
|
+
function chipify(node, start, end, p) {
|
|
2869
|
+
const after = document.createTextNode(" " + node.nodeValue.slice(end));
|
|
2870
|
+
node.nodeValue = node.nodeValue.slice(0, start);
|
|
2871
|
+
const chip = mentionChip(p);
|
|
2872
|
+
node.after(chip, after);
|
|
2873
|
+
return after;
|
|
2874
|
+
}
|
|
2875
|
+
function pick(i) {
|
|
2876
|
+
const p = m.items[i];
|
|
2877
|
+
if (!p) return close();
|
|
2878
|
+
const after = chipify(m.node, m.start, m.end, p);
|
|
2879
|
+
close();
|
|
2880
|
+
editor.focus();
|
|
2881
|
+
placeCaret(after, 1);
|
|
2882
|
+
}
|
|
2883
|
+
function chipifyComplete() {
|
|
2884
|
+
const room = currentRoom();
|
|
2885
|
+
if (!room) return;
|
|
2886
|
+
const c = caret();
|
|
2887
|
+
for (const node of [...editor.childNodes, ...[...editor.querySelectorAll("div, p")].flatMap((b) => [...b.childNodes])]) {
|
|
2888
|
+
if (node.nodeType !== Node.TEXT_NODE) continue;
|
|
2889
|
+
for (const match of [...node.nodeValue.matchAll(RULE_MENTION_RE)].reverse()) {
|
|
2890
|
+
const end = match.index + match[0].length;
|
|
2891
|
+
if (!/\s/.test(node.nodeValue[end] || "")) continue;
|
|
2892
|
+
const p = findByName(room, match[1]);
|
|
2893
|
+
if (!p) continue;
|
|
2894
|
+
const caretHere = c && c.node === node ? c.offset : -1;
|
|
2895
|
+
const after = chipify(node, match.index, end + 1, p);
|
|
2896
|
+
if (caretHere >= end + 1) placeCaret(after, caretHere - end);
|
|
2897
|
+
break;
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
editor.addEventListener("input", () => {
|
|
2902
|
+
m.index = 0;
|
|
2903
|
+
chipifyComplete();
|
|
2904
|
+
render();
|
|
2905
|
+
});
|
|
2906
|
+
editor.addEventListener("paste", (e) => {
|
|
2907
|
+
e.preventDefault();
|
|
2908
|
+
document.execCommand("insertText", false, (e.clipboardData || window.clipboardData).getData("text/plain"));
|
|
2909
|
+
});
|
|
2910
|
+
editor.addEventListener("blur", () => setTimeout(close, 150));
|
|
2911
|
+
editor.addEventListener("keydown", (event) => {
|
|
2912
|
+
if (!m.open) return;
|
|
2913
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
2914
|
+
event.preventDefault();
|
|
2915
|
+
m.index = (m.index + (event.key === "ArrowDown" ? 1 : m.items.length - 1)) % m.items.length;
|
|
2916
|
+
render();
|
|
2917
|
+
} else if (event.key === "Enter" || event.key === "Tab") {
|
|
2918
|
+
event.preventDefault();
|
|
2919
|
+
pick(m.index);
|
|
2920
|
+
} else if (event.key === "Escape") {
|
|
2921
|
+
event.preventDefault();
|
|
2922
|
+
close();
|
|
2923
|
+
}
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2601
2926
|
|
|
2602
2927
|
function attachMentions(textarea, menuEl, options) {
|
|
2603
2928
|
const opts = options || {};
|
|
@@ -2756,6 +3081,14 @@
|
|
|
2756
3081
|
els.reconnectAllBtn.addEventListener("click", () => openReconnectDialog(currentRoom()));
|
|
2757
3082
|
els.rcForm.addEventListener("submit", submitReconnect);
|
|
2758
3083
|
document.querySelectorAll("[data-close]").forEach((b) => b.addEventListener("click", () => closeDialog(b.closest("dialog"))));
|
|
3084
|
+
els.fvOpen.addEventListener("click", async () => {
|
|
3085
|
+
try {
|
|
3086
|
+
const r = await post("/api/open", { target: `${els.fvOpen.dataset.path}:1` });
|
|
3087
|
+
toast(r.message, "info");
|
|
3088
|
+
} catch (err) {
|
|
3089
|
+
showError(err);
|
|
3090
|
+
}
|
|
3091
|
+
});
|
|
2759
3092
|
els.focusBtn.addEventListener("click", async () => {
|
|
2760
3093
|
const room = currentRoom();
|
|
2761
3094
|
if (!room || room.focused || els.focusBtn.classList.contains("busy")) return;
|
|
@@ -2809,7 +3142,12 @@
|
|
|
2809
3142
|
if (link) {
|
|
2810
3143
|
e.preventDefault();
|
|
2811
3144
|
try {
|
|
2812
|
-
const
|
|
3145
|
+
const target = link.dataset.open;
|
|
3146
|
+
if (!/^(https?:|mailto:)/i.test(target) && VIEWABLE_RE.test(target)) {
|
|
3147
|
+
await viewFile(target);
|
|
3148
|
+
return;
|
|
3149
|
+
}
|
|
3150
|
+
const r = await post("/api/open", { target });
|
|
2813
3151
|
if (r.action !== "open-url") toast(r.message, "info");
|
|
2814
3152
|
} catch (err) {
|
|
2815
3153
|
showError(err);
|
|
@@ -2818,7 +3156,7 @@
|
|
|
2818
3156
|
}
|
|
2819
3157
|
const src = e.target.closest && e.target.closest(".mm-src");
|
|
2820
3158
|
if (src) {
|
|
2821
|
-
const code = src.closest(".mermaid-block").querySelector(".mm-code");
|
|
3159
|
+
const code = src.closest(".mermaid-block, .csv-block").querySelector(".mm-code");
|
|
2822
3160
|
code.hidden = !code.hidden;
|
|
2823
3161
|
src.textContent = code.hidden ? "source" : "hide source";
|
|
2824
3162
|
}
|
package/ui/index.html
CHANGED
|
@@ -210,6 +210,18 @@
|
|
|
210
210
|
<div class="actions"><button type="button" class="btn ghost" data-close>Keep my vibe</button><button type="submit" id="erase-submit" class="btn danger solid" disabled>Erase everything</button></div>
|
|
211
211
|
</form>
|
|
212
212
|
</dialog>
|
|
213
|
+
<dialog id="file-dialog" class="dialog wide file-dialog">
|
|
214
|
+
<div class="file-head">
|
|
215
|
+
<h3><span class="h-ico" data-icon="link"></span><span id="fv-title">File</span></h3>
|
|
216
|
+
<p class="lead" id="fv-path"></p>
|
|
217
|
+
</div>
|
|
218
|
+
<div id="fv-body" class="file-view"></div>
|
|
219
|
+
<div class="actions">
|
|
220
|
+
<button type="button" id="fv-open" class="btn ghost">Open in editor</button>
|
|
221
|
+
<button type="button" class="btn primary" data-close>Close</button>
|
|
222
|
+
</div>
|
|
223
|
+
</dialog>
|
|
224
|
+
<script src="/vendor/marked.umd.js"></script>
|
|
213
225
|
<script src="/icons.js"></script>
|
|
214
226
|
<script src="/avatars.js"></script>
|
|
215
227
|
<script src="/app.js"></script>
|
package/ui/theme.css
CHANGED
|
@@ -200,6 +200,12 @@ pre { background: var(--code-bg); color: #f4f2ff; padding: 10px 12px; border-rad
|
|
|
200
200
|
}
|
|
201
201
|
.select:disabled, .field select:disabled, .input:disabled, .field input:disabled { opacity: 0.55; cursor: default; }
|
|
202
202
|
.textarea, .field textarea { resize: vertical; min-height: 76px; height: auto; padding: 10px 14px; line-height: 1.5; }
|
|
203
|
+
.field .rules-editor { display: block; width: 100%; box-sizing: border-box; min-height: 118px; max-height: 320px; overflow-y: auto; padding: 10px 14px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; border: 2px solid var(--lav); border-radius: var(--r-sm); background: var(--soft); color: var(--ink); font-size: 14px; font-weight: 700; font-family: inherit; }
|
|
204
|
+
.field .rules-editor:hover { border-color: var(--lav-2); }
|
|
205
|
+
.field .rules-editor:focus { outline: none; border-color: var(--primary); background: var(--card); }
|
|
206
|
+
.field .rules-editor:empty::before { content: attr(data-placeholder); color: var(--placeholder); pointer-events: none; }
|
|
207
|
+
.mention-chip { display: inline-flex; align-items: center; gap: 4px; vertical-align: baseline; padding: 0 6px 0 2px; border-radius: 7px; background: var(--soft); font-weight: 800; white-space: nowrap; line-height: 1.35; user-select: all; }
|
|
208
|
+
.mention-chip .chip-name { color: inherit; }
|
|
203
209
|
.field.inline-num input { width: 84px; }
|
|
204
210
|
.field-grid { display: grid; gap: 8px 10px; }
|
|
205
211
|
.field-grid.two { grid-template-columns: 1fr 1fr; }
|
|
@@ -349,13 +355,15 @@ p .geek-tip, .hint > .geek-tip, .switch .label > .geek-tip { margin-left: 8px; }
|
|
|
349
355
|
.open-link { color: var(--primary); font-weight: 800; text-decoration: underline; text-decoration-color: rgba(91, 91, 240, 0.35); text-underline-offset: 2px; cursor: pointer; overflow-wrap: anywhere; }
|
|
350
356
|
.open-link:hover { text-decoration-color: var(--primary); }
|
|
351
357
|
code .open-link { color: inherit; }
|
|
352
|
-
.mermaid-block { margin: 8px 0; background: var(--card); border-radius: 14px; padding: 10px 12px 4px; max-width: 100%; overflow-x: auto; box-shadow: var(--edge); }
|
|
358
|
+
.mermaid-block, .csv-block { margin: 8px 0; background: var(--card); border-radius: 14px; padding: 10px 12px 4px; max-width: 100%; overflow-x: auto; box-shadow: var(--edge); }
|
|
353
359
|
.mermaid-block .mm-out svg { max-width: 100%; height: auto; display: block; margin: 0 auto; }
|
|
354
360
|
.mermaid-block .mm-out pre { margin: 0; }
|
|
355
|
-
.mermaid-block .mm-bar { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 2px; }
|
|
356
|
-
.mermaid-block .mm-src { border: 0; background: transparent; color: var(--faint); font-size: 11px; font-weight: 800; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
|
|
357
|
-
.mermaid-block .mm-src:hover { background: var(--soft); color: var(--primary); }
|
|
358
|
-
.mermaid-block .mm-code { margin: 6px 0 0; }
|
|
361
|
+
.mermaid-block .mm-bar, .csv-block .mm-bar { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 2px; }
|
|
362
|
+
.mermaid-block .mm-src, .csv-block .mm-src { border: 0; background: transparent; color: var(--faint); font-size: 11px; font-weight: 800; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
|
|
363
|
+
.mermaid-block .mm-src:hover, .csv-block .mm-src:hover { background: var(--soft); color: var(--primary); }
|
|
364
|
+
.mermaid-block .mm-code, .csv-block .mm-code { margin: 6px 0 0; }
|
|
365
|
+
.csv-block .csv-wrap { box-shadow: none; max-height: 320px; }
|
|
366
|
+
.csv-block .csv-count { margin: 4px 2px 0; }
|
|
359
367
|
.mermaid-block.preview { margin-top: 4px; }
|
|
360
368
|
.mermaid-block.preview .mm-bar { display: none; }
|
|
361
369
|
.diagram-presets .chip-btn { display: inline-flex; align-items: center; gap: 6px; }
|