pi-roundtable-web 0.24.2 → 0.25.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/CHANGELOG.md +33 -0
- package/README.md +51 -9
- package/dist/chunk-action-menu-qe839g4b.js +1 -0
- package/dist/chunk-agent-0pm9p3nn.js +1 -0
- package/dist/chunk-arranged-rooms-enpfswmg.js +1 -0
- package/dist/chunk-character-creator-6yf29xwx.js +1 -0
- package/dist/chunk-codeeditor-gdbdg7rp.js +2 -0
- package/dist/chunk-confirm-xrw7d732.js +1 -0
- package/dist/chunk-connectors-anhghy3k.js +1 -0
- package/dist/chunk-conversations-ry228aes.js +1 -0
- package/dist/chunk-drag-q4fx9b22.js +1 -0
- package/dist/chunk-entry-he1embgp.js +1 -0
- package/dist/chunk-folder-list-5cp8stna.js +1 -0
- package/dist/chunk-folds-hff104tk.js +1 -0
- package/dist/chunk-group-scene-h228jdat.js +1 -0
- package/dist/chunk-index-0p5h2kdz.js +2 -0
- package/dist/chunk-index-0pt08yd9.js +1 -0
- package/dist/chunk-index-12bw9yj8.js +2 -0
- package/dist/chunk-index-18gxy79n.js +47 -0
- package/dist/{chunk-index-mpm90318.js → chunk-index-27qca4mr.js} +2 -2
- package/dist/chunk-index-2ad17eh4.js +2 -0
- package/dist/chunk-index-31wh5twj.js +52 -0
- package/dist/chunk-index-4dy94pbz.js +2 -0
- package/dist/chunk-index-4ecjtdy8.js +13 -0
- package/dist/chunk-index-4v1b7ks9.js +2 -0
- package/dist/chunk-index-5d8yjj4m.js +2 -0
- package/dist/chunk-index-89ajmxh7.js +2 -0
- package/dist/chunk-index-9b1kt7qw.css +1 -0
- package/dist/chunk-index-9jqcskb4.js +14 -0
- package/dist/chunk-index-b9ny4623.js +2 -0
- package/dist/chunk-index-bvfqjh77.js +2 -0
- package/dist/chunk-index-d5abvq7v.js +2 -0
- package/dist/chunk-index-e1dr6g10.js +2 -0
- package/dist/chunk-index-gpkzhq1g.js +2 -0
- package/dist/chunk-index-hk3xyy20.js +2 -0
- package/dist/chunk-index-jm23vn03.js +4 -0
- package/dist/chunk-index-k7gh0jvz.js +2 -0
- package/dist/{chunk-index-6t68gbex.js → chunk-index-krtk6nn2.js} +2 -2
- package/dist/chunk-index-ky42ev0b.js +2 -0
- package/dist/chunk-index-pa2yyqh0.js +2 -0
- package/dist/chunk-index-pcvt668q.js +2 -0
- package/dist/chunk-index-pqw27eye.js +1 -0
- package/dist/chunk-index-s681nny9.js +2 -0
- package/dist/chunk-index-wmbmmrd4.js +302 -0
- package/dist/chunk-index-ymbjrsp6.js +2 -0
- package/dist/chunk-index-zfd00hwb.js +2 -0
- package/dist/chunk-lang-css-t85r8rz0.js +1 -0
- package/dist/{chunk-lang-dockerfile-ey0nvhhm.js → chunk-lang-dockerfile-d5mzxs60.js} +1 -1
- package/dist/{chunk-lang-go-403qsw9f.js → chunk-lang-go-830fmhba.js} +3 -3
- package/dist/chunk-lang-html-n6jparge.js +1 -0
- package/dist/chunk-lang-javascript-xj00cp97.js +1 -0
- package/dist/chunk-lang-json-5eaarz7j.js +1 -0
- package/dist/chunk-lang-markdown-zxx8eke8.js +6 -0
- package/dist/{chunk-lang-python-9jk5a49y.js → chunk-lang-python-fjw84w10.js} +2 -2
- package/dist/chunk-lang-rust-5rbp2zdk.js +1 -0
- package/dist/{chunk-lang-shell-cgq6q0kj.js → chunk-lang-shell-vzagpym3.js} +1 -1
- package/dist/chunk-lang-sql-0c2w9c2y.js +2 -0
- package/dist/{chunk-lang-toml-xh45nm68.js → chunk-lang-toml-yr9fv6eb.js} +1 -1
- package/dist/chunk-lang-typescript-8f6yywt3.js +1 -0
- package/dist/{chunk-lang-xml-1mh879j9.js → chunk-lang-xml-eze08p42.js} +1 -1
- package/dist/{chunk-lang-yaml-jac98srt.js → chunk-lang-yaml-c4b6ft3y.js} +1 -1
- package/dist/chunk-layout-actions-bv0chj41.js +1 -0
- package/dist/chunk-menu-items-jrwxycr1.js +2 -0
- package/dist/chunk-notes-9b7pgqfe.js +1 -0
- package/dist/chunk-overview-8yw71esz.js +1 -0
- package/dist/chunk-palette-frms7axe.js +1 -0
- package/dist/chunk-placement-items-fvfy9m0y.js +1 -0
- package/dist/chunk-prompt-history-yrqehads.js +1 -0
- package/dist/chunk-prompts-g6609tqx.js +1 -0
- package/dist/chunk-room-confirms-0a2y0925.js +1 -0
- package/dist/chunk-room-menu-g11q97br.js +1 -0
- package/dist/chunk-rooms-menu-button-h9qge48n.js +1 -0
- package/dist/chunk-schedules-ezmacrbj.js +1 -0
- package/dist/chunk-seat-menu-jv4s7hmj.js +1 -0
- package/dist/chunk-sheet-hosts-b0azyfhb.js +1 -0
- package/dist/chunk-skill-editor-h6k6whwg.js +8 -0
- package/dist/chunk-skills-gqcfhq56.js +1 -0
- package/dist/chunk-switcher-list-mcpamqnj.js +1 -0
- package/dist/chunk-table-s8cde1xz.js +1 -0
- package/dist/chunk-table-seats-wqkjn8bc.js +1 -0
- package/dist/chunk-table2d-4sje1fr0.js +1 -0
- package/dist/chunk-table3d-h64skazp.js +4220 -0
- package/dist/chunk-transcript-c59c6mpm.js +1 -0
- package/dist/chunk-workspace-0cyspkk4.js +1 -0
- package/dist/chunk-zh-tw-nc93jm10.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +3 -3
- package/src/api-types.ts +214 -2
- package/src/app/api-http.ts +66 -0
- package/src/app/app.ts +18 -1
- package/src/app/card-log.ts +6 -0
- package/src/app/layout-routes.ts +239 -0
- package/src/app/layout.ts +133 -0
- package/src/app/prompt-routes.ts +167 -0
- package/src/app/room-log.ts +7 -0
- package/src/app/rooms.ts +23 -0
- package/src/app/routes.ts +155 -131
- package/src/app/skill-doc-routes.ts +122 -0
- package/src/app/team-routes.ts +457 -0
- package/src/app/threads.ts +14 -0
- package/src/i18n/zh-tw.ts +177 -10
- package/src/layout-order.ts +210 -0
- package/src/push/mutes.ts +17 -0
- package/src/web-plugin.ts +25 -0
- package/dist/chunk-action-menu-7v5fce2v.js +0 -1
- package/dist/chunk-agent-2rw4ydw2.js +0 -1
- package/dist/chunk-agent-sheet-2q24dkkf.js +0 -3
- package/dist/chunk-character-creator-8q6ympxj.js +0 -1
- package/dist/chunk-codeeditor-fgmq298t.js +0 -2
- package/dist/chunk-connectors-mr53r7fh.js +0 -1
- package/dist/chunk-conversations-4vy2z6w9.js +0 -1
- package/dist/chunk-entry-jg1eem82.js +0 -1
- package/dist/chunk-group-scene-na95b1te.js +0 -1
- package/dist/chunk-index-11rjg8a8.js +0 -1
- package/dist/chunk-index-1y50d6sj.js +0 -2
- package/dist/chunk-index-5y2r0eaj.js +0 -47
- package/dist/chunk-index-7fphxxzd.js +0 -14
- package/dist/chunk-index-80f1zkp6.css +0 -1
- package/dist/chunk-index-87h45frg.js +0 -2
- package/dist/chunk-index-bnwmssyn.js +0 -2
- package/dist/chunk-index-ckgbszwt.js +0 -51
- package/dist/chunk-index-fmjjekhd.js +0 -2
- package/dist/chunk-index-t1m65yn6.js +0 -13
- package/dist/chunk-index-t711t17m.js +0 -2
- package/dist/chunk-index-txv7qvyy.js +0 -2
- package/dist/chunk-index-wr3dg24d.js +0 -2
- package/dist/chunk-index-yx683d5n.js +0 -2
- package/dist/chunk-lang-css-fkdg3x8x.js +0 -1
- package/dist/chunk-lang-html-v8h9g977.js +0 -1
- package/dist/chunk-lang-javascript-ykb2vhaa.js +0 -1
- package/dist/chunk-lang-json-2rze3rpy.js +0 -1
- package/dist/chunk-lang-markdown-6w2htnfc.js +0 -6
- package/dist/chunk-lang-rust-24fggcqx.js +0 -1
- package/dist/chunk-lang-sql-gjywb9ke.js +0 -2
- package/dist/chunk-lang-typescript-qh6rebyb.js +0 -1
- package/dist/chunk-menu-items-zjpyj51b.js +0 -2
- package/dist/chunk-notes-sjba7kw3.js +0 -1
- package/dist/chunk-overview-26r77kb6.js +0 -1
- package/dist/chunk-room-confirms-851184ep.js +0 -1
- package/dist/chunk-room-menu-8y9c026y.js +0 -1
- package/dist/chunk-skills-56s4hdbn.js +0 -1
- package/dist/chunk-table-qykpvp32.js +0 -1
- package/dist/chunk-table2d-k60ressj.js +0 -1
- package/dist/chunk-table3d-bv2ajk39.js +0 -4220
- package/dist/chunk-transcript-vekz1mh6.js +0 -1
- package/dist/chunk-workspace-hevefd0d.js +0 -1
- package/dist/chunk-zh-tw-5r2wjre0.js +0 -1
package/src/app/app.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
} from "pi-roundtable";
|
|
15
15
|
import { channelKey } from "pi-roundtable";
|
|
16
16
|
import type { Authenticated, Authenticator } from "../auth/authenticator.ts";
|
|
17
|
+
import type { ConsolePresentation } from "../features.ts";
|
|
17
18
|
import { PushApi } from "../push/api.ts";
|
|
18
19
|
import { webPushChannel } from "../push/channel.ts";
|
|
19
20
|
import { EndpointPolicy } from "../push/endpoint.ts";
|
|
@@ -26,10 +27,11 @@ import type { Vapid } from "../push/vapid.ts";
|
|
|
26
27
|
import { CardLog } from "./card-log.ts";
|
|
27
28
|
import { OwnerDesk } from "./desk.ts";
|
|
28
29
|
import { FileDir } from "./files.ts";
|
|
30
|
+
import { RoomLayout } from "./layout.ts";
|
|
29
31
|
import { Presence } from "./presence.ts";
|
|
30
32
|
import { RoomLog } from "./room-log.ts";
|
|
31
33
|
import { Rooms } from "./rooms.ts";
|
|
32
|
-
import { AppApi, partsOf } from "./routes.ts";
|
|
34
|
+
import { AppApi, type AppApiOptions, partsOf } from "./routes.ts";
|
|
33
35
|
import { SocketRoute } from "./socket.ts";
|
|
34
36
|
import { type AppSocket, Sockets } from "./sockets.ts";
|
|
35
37
|
import { AppSurface } from "./surface.ts";
|
|
@@ -49,9 +51,12 @@ export const APP_MIGRATIONS: readonly Migration[] = [
|
|
|
49
51
|
WebThreads.migration,
|
|
50
52
|
PushSubscriptions.migration,
|
|
51
53
|
PushMutes.migration,
|
|
54
|
+
RoomLayout.migration,
|
|
52
55
|
];
|
|
53
56
|
|
|
54
57
|
export interface OwnerAppOptions {
|
|
58
|
+
/** The host's wording, for the refusals the API answers with. */
|
|
59
|
+
presentation?: ConsolePresentation;
|
|
55
60
|
/** The key prefix of the rooms, such as `app`. */
|
|
56
61
|
surface: string;
|
|
57
62
|
/** The path the console is served under, without a trailing slash. */
|
|
@@ -87,6 +92,13 @@ export interface OwnerAppOptions {
|
|
|
87
92
|
};
|
|
88
93
|
database: SQL;
|
|
89
94
|
agents: AgentServer;
|
|
95
|
+
/**
|
|
96
|
+
* Present when the host has the skills addon: the catalog an agent's skills are chosen from,
|
|
97
|
+
* how they change, and the registry's documents.
|
|
98
|
+
*/
|
|
99
|
+
skills?: AppApiOptions["skills"];
|
|
100
|
+
/** Present when the host has prompt documents: the main prompts the owner edits. */
|
|
101
|
+
prompts?: AppApiOptions["prompts"];
|
|
90
102
|
/** Present when the workspace pane is served. */
|
|
91
103
|
workspace?: WorkspaceFiles;
|
|
92
104
|
modes: ConversationModes;
|
|
@@ -259,6 +271,7 @@ export class OwnerApp {
|
|
|
259
271
|
push,
|
|
260
272
|
});
|
|
261
273
|
this.#api = new AppApi({
|
|
274
|
+
...(options.presentation ? { presentation: options.presentation } : {}),
|
|
262
275
|
surface,
|
|
263
276
|
rooms,
|
|
264
277
|
log,
|
|
@@ -267,12 +280,16 @@ export class OwnerApp {
|
|
|
267
280
|
files: stored,
|
|
268
281
|
modes,
|
|
269
282
|
mutes,
|
|
283
|
+
threads,
|
|
284
|
+
layout: new RoomLayout(options.database),
|
|
270
285
|
conversations: options.conversations,
|
|
271
286
|
identity,
|
|
272
287
|
...(options.schedules ? { schedules: options.schedules } : {}),
|
|
273
288
|
...(options.workspace ? { workspace: options.workspace } : {}),
|
|
274
289
|
directory: agents.directory,
|
|
275
290
|
team: agents.team,
|
|
291
|
+
...(options.skills ? { skills: options.skills } : {}),
|
|
292
|
+
...(options.prompts ? { prompts: options.prompts } : {}),
|
|
276
293
|
avatars: agents.avatars,
|
|
277
294
|
mount: options.mount,
|
|
278
295
|
authMode: options.authMode,
|
package/src/app/card-log.ts
CHANGED
|
@@ -95,6 +95,12 @@ export class CardLog {
|
|
|
95
95
|
ON CONFLICT (prompt_id) DO NOTHING`;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** Deletes every ended card of a room that is gone for good, once the writes under way are in. */
|
|
99
|
+
async removeRoom(room: string): Promise<void> {
|
|
100
|
+
await Promise.allSettled([...this.#writing]);
|
|
101
|
+
await this.#sql`DELETE FROM web_room_cards WHERE room = ${room}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
98
104
|
/**
|
|
99
105
|
* The room's cards that opened at or after `from` (all of them when absent) and before the
|
|
100
106
|
* stored message `before` of the same room was written (all of them when absent), oldest
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { Speaker } from "pi-roundtable";
|
|
2
|
+
import type { ApiError, LayoutView } from "../api-types.ts";
|
|
3
|
+
import type { Authenticated } from "../auth/authenticator.ts";
|
|
4
|
+
import { json } from "../http.ts";
|
|
5
|
+
import {
|
|
6
|
+
createFolder,
|
|
7
|
+
LayoutError,
|
|
8
|
+
placeFolder,
|
|
9
|
+
placeRoom,
|
|
10
|
+
removeFolder,
|
|
11
|
+
renameFolder,
|
|
12
|
+
setPinned,
|
|
13
|
+
shown,
|
|
14
|
+
} from "../layout-order.ts";
|
|
15
|
+
import type { PushMutes } from "../push/mutes.ts";
|
|
16
|
+
import { readLimited } from "../request-body.ts";
|
|
17
|
+
import type { LayoutStore } from "./layout.ts";
|
|
18
|
+
import type { ResolvedRoom } from "./rooms.ts";
|
|
19
|
+
|
|
20
|
+
/** The largest JSON body of a layout write: 4 KiB. */
|
|
21
|
+
const MAX_BODY_BYTES = 4 * 1024;
|
|
22
|
+
const FOLDER_ID = /^folder\.[0-9a-f-]{36}$/;
|
|
23
|
+
|
|
24
|
+
/** A refusal with the status the owner is answered with. */
|
|
25
|
+
class LayoutRefusal extends Error {
|
|
26
|
+
constructor(
|
|
27
|
+
readonly status: number,
|
|
28
|
+
message: string,
|
|
29
|
+
) {
|
|
30
|
+
super(message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const bad = (message: string) => new LayoutRefusal(400, message);
|
|
35
|
+
|
|
36
|
+
/** A refusal of the pure layout edits as the answer the owner reads. */
|
|
37
|
+
function refusalOf(error: LayoutError): LayoutRefusal {
|
|
38
|
+
switch (error.code) {
|
|
39
|
+
case "bad-name":
|
|
40
|
+
return new LayoutRefusal(
|
|
41
|
+
400,
|
|
42
|
+
"Give the folder a name of 1 to 40 characters.",
|
|
43
|
+
);
|
|
44
|
+
case "no-folder":
|
|
45
|
+
return new LayoutRefusal(404, "There is no such folder.");
|
|
46
|
+
case "duplicate":
|
|
47
|
+
return new LayoutRefusal(409, "A folder of that name exists.");
|
|
48
|
+
case "full":
|
|
49
|
+
return new LayoutRefusal(409, "There are 50 folders already.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
|
54
|
+
const raw = await readLimited(request, MAX_BODY_BYTES);
|
|
55
|
+
if (!raw) throw new LayoutRefusal(413, "The body is too large.");
|
|
56
|
+
let value: unknown;
|
|
57
|
+
try {
|
|
58
|
+
value = JSON.parse(raw.toString("utf8"));
|
|
59
|
+
} catch {
|
|
60
|
+
throw bad("The body is not JSON.");
|
|
61
|
+
}
|
|
62
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
63
|
+
throw bad("The body is not an object.");
|
|
64
|
+
return value as Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `before` of a body: a key or id, none for the end; anything else is refused. */
|
|
68
|
+
function beforeOf(body: Record<string, unknown>): string | null {
|
|
69
|
+
const { before } = body;
|
|
70
|
+
if (before === undefined || before === null) return null;
|
|
71
|
+
if (typeof before !== "string") throw bad("before must be a key or null.");
|
|
72
|
+
return before;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface LayoutRoutesOptions {
|
|
76
|
+
layout: LayoutStore;
|
|
77
|
+
/** The rooms whose push the owners muted. */
|
|
78
|
+
mutes: Pick<PushMutes, "setMany">;
|
|
79
|
+
/** The keys of the rooms that may be placed: the active agents' and groups' rooms but Home. */
|
|
80
|
+
placeable(): ReadonlySet<string>;
|
|
81
|
+
/** Home's key, which stays at the top. */
|
|
82
|
+
home(): string | undefined;
|
|
83
|
+
/** Reads the person's tier again; refuses with 403 when they are no owner. */
|
|
84
|
+
owner(admission: Authenticated): Promise<Speaker>;
|
|
85
|
+
/** Called after a change the other pages should refetch. */
|
|
86
|
+
changed(): void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The room list's arrangement over HTTP: `GET api/rooms` carries it, `api/folders` makes, renames,
|
|
91
|
+
* places and removes folders, and `PUT api/rooms/<key>/folder` and `…/pin` place one room. Every
|
|
92
|
+
* write is an owner's (tier read again) and answers with the whole layout, so the writing page
|
|
93
|
+
* and the others converge on what the server stored.
|
|
94
|
+
*/
|
|
95
|
+
export class LayoutRoutes {
|
|
96
|
+
readonly #options: LayoutRoutesOptions;
|
|
97
|
+
|
|
98
|
+
constructor(options: LayoutRoutesOptions) {
|
|
99
|
+
this.#options = options;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The layout as readers see it: only the rooms that exist. */
|
|
103
|
+
async view(): Promise<LayoutView> {
|
|
104
|
+
const rooms = this.#options.placeable();
|
|
105
|
+
return shown(await this.#options.layout.view(), (key) => rooms.has(key));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `api/folders` and `api/folders/<id>`; `parts` is the path split after the mount. */
|
|
109
|
+
async folders(
|
|
110
|
+
request: Request,
|
|
111
|
+
parts: string[],
|
|
112
|
+
admission: Authenticated,
|
|
113
|
+
): Promise<Response> {
|
|
114
|
+
return this.#guard(async () => {
|
|
115
|
+
const { method } = request;
|
|
116
|
+
const [, , id, action] = parts;
|
|
117
|
+
if (parts.length > 4 || (parts.length === 4 && action !== "mute"))
|
|
118
|
+
throw new LayoutRefusal(404, notAnApi);
|
|
119
|
+
if (action === "mute") return this.#muteFolder(request, id, admission);
|
|
120
|
+
if (!id) {
|
|
121
|
+
if (method !== "POST") throw new LayoutRefusal(405, notAllowed);
|
|
122
|
+
await this.#options.owner(admission);
|
|
123
|
+
const { name } = await bodyOf(request);
|
|
124
|
+
if (typeof name !== "string")
|
|
125
|
+
throw new LayoutRefusal(
|
|
126
|
+
400,
|
|
127
|
+
"Give the folder a name of 1 to 40 characters.",
|
|
128
|
+
);
|
|
129
|
+
const folder = `folder.${crypto.randomUUID()}`;
|
|
130
|
+
return json(
|
|
131
|
+
await this.#write((layout) => createFolder(layout, folder, name)),
|
|
132
|
+
201,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (method !== "PATCH" && method !== "DELETE")
|
|
136
|
+
throw new LayoutRefusal(405, notAllowed);
|
|
137
|
+
await this.#options.owner(admission);
|
|
138
|
+
if (!FOLDER_ID.test(id))
|
|
139
|
+
throw new LayoutRefusal(404, "There is no such folder.");
|
|
140
|
+
if (method === "DELETE")
|
|
141
|
+
return json(await this.#write((layout) => removeFolder(layout, id)));
|
|
142
|
+
const body = await bodyOf(request);
|
|
143
|
+
const { name } = body;
|
|
144
|
+
if (name !== undefined && typeof name !== "string")
|
|
145
|
+
throw bad("name must be text.");
|
|
146
|
+
const placed = "before" in body;
|
|
147
|
+
if (name === undefined && !placed) throw bad("Give a name or a place.");
|
|
148
|
+
const before = beforeOf(body);
|
|
149
|
+
return json(
|
|
150
|
+
await this.#write((layout) => {
|
|
151
|
+
let next = layout;
|
|
152
|
+
if (name !== undefined) next = renameFolder(next, id, name);
|
|
153
|
+
if (placed) next = placeFolder(next, id, before);
|
|
154
|
+
return next;
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `PUT api/folders/<id>/mute`: mutes or unmutes every room in the folder as one step, through the
|
|
162
|
+
* rooms' own mutes. A room moved into the folder later keeps its own mute.
|
|
163
|
+
*/
|
|
164
|
+
async #muteFolder(
|
|
165
|
+
request: Request,
|
|
166
|
+
id: string | undefined,
|
|
167
|
+
admission: Authenticated,
|
|
168
|
+
): Promise<Response> {
|
|
169
|
+
if (request.method !== "PUT") throw new LayoutRefusal(405, notAllowed);
|
|
170
|
+
const owner = await this.#options.owner(admission);
|
|
171
|
+
const folder = (await this.view()).folders.find((one) => one.id === id);
|
|
172
|
+
if (!folder) throw new LayoutRefusal(404, "There is no such folder.");
|
|
173
|
+
const { muted } = await bodyOf(request);
|
|
174
|
+
if (typeof muted !== "boolean") throw bad("muted must be true or false.");
|
|
175
|
+
await this.#options.mutes.setMany(folder.rooms, muted, owner.principalId);
|
|
176
|
+
this.#options.changed();
|
|
177
|
+
return json({ muted, rooms: folder.rooms });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** `PUT api/rooms/<key>/folder` and `…/pin`, for a room that resolved. */
|
|
181
|
+
async place(
|
|
182
|
+
request: Request,
|
|
183
|
+
room: ResolvedRoom,
|
|
184
|
+
action: "folder" | "pin",
|
|
185
|
+
admission: Authenticated,
|
|
186
|
+
): Promise<Response> {
|
|
187
|
+
return this.#guard(async () => {
|
|
188
|
+
await this.#options.owner(admission);
|
|
189
|
+
if (room.kind === "thread") throw bad("A thread stays under its room.");
|
|
190
|
+
if (room.key === this.#options.home())
|
|
191
|
+
throw bad("Home stays at the top.");
|
|
192
|
+
const body = await bodyOf(request);
|
|
193
|
+
const before = beforeOf(body);
|
|
194
|
+
if (action === "pin") {
|
|
195
|
+
const { pinned } = body;
|
|
196
|
+
if (typeof pinned !== "boolean")
|
|
197
|
+
throw bad("pinned must be true or false.");
|
|
198
|
+
return json(
|
|
199
|
+
await this.#write((layout) =>
|
|
200
|
+
setPinned(layout, room.key, pinned, before),
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const { folder } = body;
|
|
205
|
+
if (folder !== null && typeof folder !== "string")
|
|
206
|
+
throw bad("folder must be a folder id or null.");
|
|
207
|
+
return json(
|
|
208
|
+
await this.#write((layout) =>
|
|
209
|
+
placeRoom(layout, room.key, folder, before),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Stores an edit, tells the other pages, and answers with the layout as readers see it. */
|
|
216
|
+
async #write(edit: (layout: LayoutView) => LayoutView): Promise<LayoutView> {
|
|
217
|
+
const stored = await this.#options.layout.change(edit);
|
|
218
|
+
this.#options.changed();
|
|
219
|
+
const rooms = this.#options.placeable();
|
|
220
|
+
return shown(stored, (key) => rooms.has(key));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async #guard(run: () => Promise<Response>): Promise<Response> {
|
|
224
|
+
try {
|
|
225
|
+
return await run();
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const refusal = error instanceof LayoutError ? refusalOf(error) : error;
|
|
228
|
+
if (refusal instanceof LayoutRefusal)
|
|
229
|
+
return json(
|
|
230
|
+
{ error: refusal.message } satisfies ApiError,
|
|
231
|
+
refusal.status,
|
|
232
|
+
);
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const notAllowed = "Method not allowed.";
|
|
239
|
+
const notAnApi = "There is no such API.";
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "pi-roundtable";
|
|
3
|
+
import type { LayoutView } from "../api-types.ts";
|
|
4
|
+
import { forgetRoom } from "../layout-order.ts";
|
|
5
|
+
|
|
6
|
+
/** What the layout routes need of the stored arrangement; `RoomLayout` is the database's. */
|
|
7
|
+
export interface LayoutStore {
|
|
8
|
+
/** Everything stored, including rooms that no longer exist. */
|
|
9
|
+
view(): Promise<LayoutView>;
|
|
10
|
+
/**
|
|
11
|
+
* Applies an edit to the stored layout as one step that no other edit overlaps, and returns the
|
|
12
|
+
* result. An edit that throws changes nothing.
|
|
13
|
+
*/
|
|
14
|
+
change(edit: (layout: LayoutView) => LayoutView): Promise<LayoutView>;
|
|
15
|
+
/**
|
|
16
|
+
* Drops a room's pin and its place in a folder, for a room that is deleted for good (an archived
|
|
17
|
+
* agent returns to its place when it comes back, so archiving does not call this).
|
|
18
|
+
*/
|
|
19
|
+
forget(room: string): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FolderRow {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
interface RoomRow {
|
|
27
|
+
room: string;
|
|
28
|
+
folder: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The arrangement of the room list, shared by every owner and device: the pinned rooms
|
|
33
|
+
* (`web_room_pins`), the folders (`web_room_folders`) and the rooms in them
|
|
34
|
+
* (`web_room_folder_rooms`). Positions are dense (0..n-1) within each list and rewritten on every
|
|
35
|
+
* change under one advisory lock, so two devices editing at once cannot leave a gap or a duplicate.
|
|
36
|
+
* Rows of rooms that no longer exist are kept, so a re-created agent returns to its place.
|
|
37
|
+
*/
|
|
38
|
+
export class RoomLayout implements LayoutStore {
|
|
39
|
+
readonly #sql: SQL;
|
|
40
|
+
|
|
41
|
+
constructor(sql: SQL) {
|
|
42
|
+
this.#sql = sql;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static readonly migration: Migration = {
|
|
46
|
+
name: "web-room-layout",
|
|
47
|
+
up: async (sql) => {
|
|
48
|
+
await sql`
|
|
49
|
+
CREATE TABLE IF NOT EXISTS web_room_folders (
|
|
50
|
+
id text PRIMARY KEY,
|
|
51
|
+
name text NOT NULL,
|
|
52
|
+
position integer NOT NULL,
|
|
53
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
54
|
+
)`;
|
|
55
|
+
await sql`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS web_room_folder_rooms (
|
|
57
|
+
room text PRIMARY KEY,
|
|
58
|
+
folder text NOT NULL REFERENCES web_room_folders (id) ON DELETE CASCADE,
|
|
59
|
+
position integer NOT NULL
|
|
60
|
+
)`;
|
|
61
|
+
await sql`CREATE INDEX IF NOT EXISTS web_room_folder_rooms_folder ON web_room_folder_rooms (folder, position)`;
|
|
62
|
+
await sql`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS web_room_pins (
|
|
64
|
+
room text PRIMARY KEY,
|
|
65
|
+
position integer NOT NULL
|
|
66
|
+
)`;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
async forget(room: string): Promise<void> {
|
|
71
|
+
await this.change((layout) => forgetRoom(layout, room));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async view(): Promise<LayoutView> {
|
|
75
|
+
// The three SELECTs share one snapshot, so an edit that commits between them is seen whole or not at all.
|
|
76
|
+
return this.#sql.begin("isolation level repeatable read, read only", read);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async change(edit: (layout: LayoutView) => LayoutView): Promise<LayoutView> {
|
|
80
|
+
return this.#sql.begin(async (tx) => {
|
|
81
|
+
await tx`SELECT pg_advisory_xact_lock(hashtextextended('web-room-layout', 0))`;
|
|
82
|
+
const before = await read(tx);
|
|
83
|
+
const after = edit(before);
|
|
84
|
+
if (JSON.stringify(after) !== JSON.stringify(before))
|
|
85
|
+
await write(tx, after);
|
|
86
|
+
return after;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function read(tx: SQL): Promise<LayoutView> {
|
|
92
|
+
const folders: FolderRow[] = await tx`
|
|
93
|
+
SELECT id, name FROM web_room_folders ORDER BY position, created_at, id`;
|
|
94
|
+
const inside: RoomRow[] = await tx`
|
|
95
|
+
SELECT room, folder FROM web_room_folder_rooms ORDER BY folder, position`;
|
|
96
|
+
const pins: { room: string }[] = await tx`
|
|
97
|
+
SELECT room FROM web_room_pins ORDER BY position`;
|
|
98
|
+
return {
|
|
99
|
+
pins: pins.map((row) => row.room),
|
|
100
|
+
folders: folders.map((folder) => ({
|
|
101
|
+
id: folder.id,
|
|
102
|
+
name: folder.name,
|
|
103
|
+
rooms: inside
|
|
104
|
+
.filter((row) => row.folder === folder.id)
|
|
105
|
+
.map((r) => r.room),
|
|
106
|
+
})),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Rewrites the three tables to match the layout, positions dense. */
|
|
111
|
+
async function write(tx: SQL, layout: LayoutView): Promise<void> {
|
|
112
|
+
await tx`DELETE FROM web_room_folder_rooms`;
|
|
113
|
+
await tx`DELETE FROM web_room_pins`;
|
|
114
|
+
const ids = layout.folders.map((folder) => folder.id);
|
|
115
|
+
if (ids.length === 0) await tx`DELETE FROM web_room_folders`;
|
|
116
|
+
else await tx`DELETE FROM web_room_folders WHERE id NOT IN ${tx(ids)}`;
|
|
117
|
+
for (const [position, folder] of layout.folders.entries())
|
|
118
|
+
await tx`
|
|
119
|
+
INSERT INTO web_room_folders (id, name, position)
|
|
120
|
+
VALUES (${folder.id}, ${folder.name}, ${position})
|
|
121
|
+
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, position = EXCLUDED.position`;
|
|
122
|
+
const pins = layout.pins.map((room, position) => ({ room, position }));
|
|
123
|
+
if (pins.length > 0) await tx`INSERT INTO web_room_pins ${tx(pins)}`;
|
|
124
|
+
const placed = layout.folders.flatMap((folder) =>
|
|
125
|
+
folder.rooms.map((room, position) => ({
|
|
126
|
+
room,
|
|
127
|
+
folder: folder.id,
|
|
128
|
+
position,
|
|
129
|
+
})),
|
|
130
|
+
);
|
|
131
|
+
if (placed.length > 0)
|
|
132
|
+
await tx`INSERT INTO web_room_folder_rooms ${tx(placed)}`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PromptDocuments,
|
|
3
|
+
PromptDocumentView,
|
|
4
|
+
PromptVersion,
|
|
5
|
+
} from "pi-roundtable";
|
|
6
|
+
import { PromptConflictError } from "pi-roundtable";
|
|
7
|
+
import type {
|
|
8
|
+
PromptConflict,
|
|
9
|
+
PromptHistoryView,
|
|
10
|
+
PromptRevert,
|
|
11
|
+
PromptSave,
|
|
12
|
+
PromptsView,
|
|
13
|
+
PromptVersionView,
|
|
14
|
+
PromptView,
|
|
15
|
+
} from "../api-types.ts";
|
|
16
|
+
import { json } from "../http.ts";
|
|
17
|
+
import { bad, bodyOf, HttpError } from "./api-http.ts";
|
|
18
|
+
import type { OwnerOf } from "./team-routes.ts";
|
|
19
|
+
|
|
20
|
+
/** The most bytes a character takes in a JSON body: a control character is escaped as `\u00XX`. */
|
|
21
|
+
const BYTES_PER_CHAR = 6;
|
|
22
|
+
/** What a body carries besides the texts: the braces, the keys and some room. */
|
|
23
|
+
const BODY_OVERHEAD = 4 * 1024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The largest body of a write to a document: the new text, at most `maxChars` characters, and
|
|
27
|
+
* `base`, the text in force when the edit began, which may be longer (a file version has no limit).
|
|
28
|
+
*/
|
|
29
|
+
const bodyLimit = (document: PromptDocumentView): number =>
|
|
30
|
+
(Math.max(document.maxChars, document.text.length, document.fileText.length) +
|
|
31
|
+
document.maxChars) *
|
|
32
|
+
BYTES_PER_CHAR +
|
|
33
|
+
BODY_OVERHEAD;
|
|
34
|
+
|
|
35
|
+
export interface PromptApiOptions {
|
|
36
|
+
prompts: Pick<PromptDocuments, "list" | "get" | "set" | "revert" | "history">;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const viewOf = (document: PromptDocumentView): PromptView => ({
|
|
40
|
+
id: document.id,
|
|
41
|
+
label: document.label,
|
|
42
|
+
...(document.description ? { description: document.description } : {}),
|
|
43
|
+
differs: document.differs,
|
|
44
|
+
...(document.broken ? { broken: document.broken } : {}),
|
|
45
|
+
...(document.override
|
|
46
|
+
? {
|
|
47
|
+
override: {
|
|
48
|
+
by: document.override.by,
|
|
49
|
+
at: document.override.at.toISOString(),
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
: {}),
|
|
53
|
+
text: document.text,
|
|
54
|
+
fileText: document.fileText,
|
|
55
|
+
maxChars: document.maxChars,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** The answer to a write, or 409 with the document as it is now when it was not the text the writer saw. */
|
|
59
|
+
async function changed(
|
|
60
|
+
write: () => Promise<PromptDocumentView>,
|
|
61
|
+
): Promise<Response> {
|
|
62
|
+
try {
|
|
63
|
+
return json(viewOf(await write()));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (!(error instanceof PromptConflictError)) throw error;
|
|
66
|
+
const refused = new HttpError(
|
|
67
|
+
409,
|
|
68
|
+
"This prompt changed while you were editing it.",
|
|
69
|
+
);
|
|
70
|
+
return json(
|
|
71
|
+
{
|
|
72
|
+
error: refused.message,
|
|
73
|
+
conflict: "changed",
|
|
74
|
+
current: viewOf(error.current),
|
|
75
|
+
} satisfies PromptConflict,
|
|
76
|
+
refused.status,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const promptVersionView = (
|
|
82
|
+
version: PromptVersion,
|
|
83
|
+
): PromptVersionView => ({
|
|
84
|
+
id: version.id,
|
|
85
|
+
action: version.action,
|
|
86
|
+
text: version.text,
|
|
87
|
+
previous: version.previous,
|
|
88
|
+
by: version.by,
|
|
89
|
+
at: version.at.toISOString(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The app's REST for the prompt documents: the main prompts the owner edits outside the files.
|
|
94
|
+
* Every call, a read too, is an owner's (the tier read again); a write is also checked for the
|
|
95
|
+
* console's `Origin` before it gets here. A save names the text it was edited from, and a document
|
|
96
|
+
* that changed meanwhile answers 409 with what is there now.
|
|
97
|
+
*/
|
|
98
|
+
export class PromptApi {
|
|
99
|
+
readonly #options: PromptApiOptions;
|
|
100
|
+
|
|
101
|
+
constructor(options: PromptApiOptions) {
|
|
102
|
+
this.#options = options;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async handle(
|
|
106
|
+
request: Request,
|
|
107
|
+
parts: string[],
|
|
108
|
+
owner: OwnerOf,
|
|
109
|
+
): Promise<Response> {
|
|
110
|
+
const { method } = request;
|
|
111
|
+
const [, , id, action] = parts;
|
|
112
|
+
const { prompts } = this.#options;
|
|
113
|
+
if (!id) {
|
|
114
|
+
if (method !== "GET") throw new HttpError(405, "Method not allowed.");
|
|
115
|
+
await owner();
|
|
116
|
+
return json({
|
|
117
|
+
documents: prompts
|
|
118
|
+
.list()
|
|
119
|
+
.map(
|
|
120
|
+
({ differs, id: key, label, description, override, broken }) => ({
|
|
121
|
+
id: key,
|
|
122
|
+
label,
|
|
123
|
+
...(description ? { description } : {}),
|
|
124
|
+
differs,
|
|
125
|
+
...(broken ? { broken } : {}),
|
|
126
|
+
...(override
|
|
127
|
+
? {
|
|
128
|
+
override: {
|
|
129
|
+
by: override.by,
|
|
130
|
+
at: override.at.toISOString(),
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
: {}),
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
} satisfies PromptsView);
|
|
137
|
+
}
|
|
138
|
+
// The tier is read before the id is looked up, so a person below the owner learns of no id.
|
|
139
|
+
const speaker = await owner();
|
|
140
|
+
const document = prompts.get(id);
|
|
141
|
+
if (!document) throw new HttpError(404, "There is no such prompt.");
|
|
142
|
+
if (!action && method === "GET") return json(viewOf(document));
|
|
143
|
+
if (!action && method === "PUT") {
|
|
144
|
+
const body = await bodyOf(request, bodyLimit(document));
|
|
145
|
+
const { text, base } = body;
|
|
146
|
+
if (typeof text !== "string") throw bad("text must be text.");
|
|
147
|
+
if (typeof base !== "string")
|
|
148
|
+
throw bad("base must be the text the prompt was edited from.");
|
|
149
|
+
const save: PromptSave = { text, base };
|
|
150
|
+
// The registry compares `base` where the write waits its turn, so two saves cannot both pass.
|
|
151
|
+
return changed(() => prompts.set(id, save.text, speaker.name, save.base));
|
|
152
|
+
}
|
|
153
|
+
if (action === "revert" && method === "POST" && parts.length === 4) {
|
|
154
|
+
const body = await bodyOf(request, bodyLimit(document));
|
|
155
|
+
if (typeof body.base !== "string")
|
|
156
|
+
throw bad("base must be the text the prompt was reverted from.");
|
|
157
|
+
const revert: PromptRevert = { base: body.base };
|
|
158
|
+
return changed(() => prompts.revert(id, speaker.name, revert.base));
|
|
159
|
+
}
|
|
160
|
+
if (action === "history" && method === "GET" && parts.length === 4) {
|
|
161
|
+
return json({
|
|
162
|
+
versions: (await prompts.history(id)).map(promptVersionView),
|
|
163
|
+
} satisfies PromptHistoryView);
|
|
164
|
+
}
|
|
165
|
+
throw new HttpError(404, "There is no such API.");
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/app/room-log.ts
CHANGED
|
@@ -123,6 +123,13 @@ export class RoomLog {
|
|
|
123
123
|
};
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/** Deletes every message of a room that is gone for good; returns how many. */
|
|
127
|
+
async removeRoom(room: string): Promise<number> {
|
|
128
|
+
const rows: unknown[] = await this
|
|
129
|
+
.#sql`DELETE FROM web_room_messages WHERE room = ${room} RETURNING 1`;
|
|
130
|
+
return rows.length;
|
|
131
|
+
}
|
|
132
|
+
|
|
126
133
|
/** The newest message of each of the rooms that has one. */
|
|
127
134
|
async lastOf(rooms: readonly string[]): Promise<Map<string, RoomMessage>> {
|
|
128
135
|
const found = new Map<string, RoomMessage>();
|
package/src/app/rooms.ts
CHANGED
|
@@ -123,6 +123,27 @@ export class Rooms {
|
|
|
123
123
|
: undefined;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The keys of the rooms an owner can pin or put in a folder: the active agents' and groups' rooms,
|
|
128
|
+
* without Home, which stays at the top, and without threads, which follow their room.
|
|
129
|
+
*/
|
|
130
|
+
placeable(): Set<string> {
|
|
131
|
+
const { surface, directory } = this.#options;
|
|
132
|
+
const home = this.homeKey();
|
|
133
|
+
return new Set<string>(
|
|
134
|
+
[
|
|
135
|
+
...directory
|
|
136
|
+
.agents()
|
|
137
|
+
.filter((agent) => agent.status === "active")
|
|
138
|
+
.map((agent) => agentRoomKey(surface, agent.name)),
|
|
139
|
+
...directory
|
|
140
|
+
.groups()
|
|
141
|
+
.filter((group) => group.status === "active")
|
|
142
|
+
.map((group) => groupRoomKey(surface, group.name)),
|
|
143
|
+
].filter((key) => key !== home),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
126
147
|
/** The agent whose avatar path to show, as a path under the mount. */
|
|
127
148
|
avatarOf(agent: Agent): string {
|
|
128
149
|
// The studio gives a full URL with a public address and a bare path without one.
|
|
@@ -136,6 +157,7 @@ export class Rooms {
|
|
|
136
157
|
const { surface, directory, modes, mutes, activity, log, threads } =
|
|
137
158
|
this.#options;
|
|
138
159
|
const home = this.homeKey();
|
|
160
|
+
const coordinator = this.#options.team.coordinator();
|
|
139
161
|
const agents = directory.agents().filter((a) => a.status === "active");
|
|
140
162
|
const groups = directory.groups().filter((g) => g.status === "active");
|
|
141
163
|
const shown = await threads.list();
|
|
@@ -163,6 +185,7 @@ export class Rooms {
|
|
|
163
185
|
displayName: agent.displayName,
|
|
164
186
|
avatar: this.avatarOf(agent),
|
|
165
187
|
character: characterOf(agent),
|
|
188
|
+
...(agent.name === coordinator ? { coordinator: true as const } : {}),
|
|
166
189
|
}),
|
|
167
190
|
),
|
|
168
191
|
...groups.map((group: AgentGroup) =>
|