pi-roundtable-web 0.23.1 → 0.24.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +90 -16
  3. package/dist/chunk-action-menu-7v5fce2v.js +1 -0
  4. package/dist/chunk-agent-2rw4ydw2.js +1 -0
  5. package/dist/chunk-agent-sheet-2q24dkkf.js +3 -0
  6. package/dist/chunk-character-creator-8q6ympxj.js +1 -0
  7. package/dist/chunk-codeeditor-fgmq298t.js +2 -0
  8. package/dist/chunk-connectors-mr53r7fh.js +1 -0
  9. package/dist/chunk-conversations-4vy2z6w9.js +1 -0
  10. package/dist/chunk-entry-jg1eem82.js +1 -0
  11. package/dist/chunk-group-scene-na95b1te.js +1 -0
  12. package/dist/chunk-index-11rjg8a8.js +1 -0
  13. package/dist/chunk-index-1y50d6sj.js +2 -0
  14. package/dist/chunk-index-5y2r0eaj.js +47 -0
  15. package/dist/chunk-index-6t68gbex.js +2 -0
  16. package/dist/chunk-index-7fphxxzd.js +14 -0
  17. package/dist/chunk-index-80f1zkp6.css +1 -0
  18. package/dist/chunk-index-87h45frg.js +2 -0
  19. package/dist/chunk-index-bnwmssyn.js +2 -0
  20. package/dist/chunk-index-ckgbszwt.js +51 -0
  21. package/dist/chunk-index-fmjjekhd.js +2 -0
  22. package/dist/chunk-index-mpm90318.js +2 -0
  23. package/dist/chunk-index-t1m65yn6.js +13 -0
  24. package/dist/chunk-index-t711t17m.js +2 -0
  25. package/dist/chunk-index-txv7qvyy.js +2 -0
  26. package/dist/chunk-index-wr3dg24d.js +2 -0
  27. package/dist/chunk-index-yx683d5n.js +2 -0
  28. package/dist/chunk-lang-css-fkdg3x8x.js +1 -0
  29. package/dist/chunk-lang-dockerfile-ey0nvhhm.js +1 -0
  30. package/dist/chunk-lang-go-403qsw9f.js +7 -0
  31. package/dist/chunk-lang-html-v8h9g977.js +1 -0
  32. package/dist/chunk-lang-javascript-ykb2vhaa.js +1 -0
  33. package/dist/chunk-lang-json-2rze3rpy.js +1 -0
  34. package/dist/chunk-lang-markdown-6w2htnfc.js +6 -0
  35. package/dist/chunk-lang-python-9jk5a49y.js +3 -0
  36. package/dist/chunk-lang-rust-24fggcqx.js +1 -0
  37. package/dist/chunk-lang-shell-cgq6q0kj.js +1 -0
  38. package/dist/chunk-lang-sql-gjywb9ke.js +2 -0
  39. package/dist/chunk-lang-toml-xh45nm68.js +1 -0
  40. package/dist/chunk-lang-typescript-qh6rebyb.js +1 -0
  41. package/dist/chunk-lang-xml-1mh879j9.js +1 -0
  42. package/dist/chunk-lang-yaml-jac98srt.js +1 -0
  43. package/dist/chunk-menu-items-zjpyj51b.js +2 -0
  44. package/dist/chunk-notes-sjba7kw3.js +1 -0
  45. package/dist/chunk-overview-26r77kb6.js +1 -0
  46. package/dist/chunk-room-confirms-851184ep.js +1 -0
  47. package/dist/chunk-room-menu-8y9c026y.js +1 -0
  48. package/dist/chunk-skills-56s4hdbn.js +1 -0
  49. package/dist/chunk-table-qykpvp32.js +1 -0
  50. package/dist/chunk-table2d-k60ressj.js +1 -0
  51. package/dist/chunk-table3d-bv2ajk39.js +4220 -0
  52. package/dist/chunk-transcript-vekz1mh6.js +1 -0
  53. package/dist/chunk-workspace-hevefd0d.js +1 -0
  54. package/dist/chunk-zh-tw-5r2wjre0.js +1 -0
  55. package/dist/index.html +2 -1
  56. package/dist/sw.js +1 -1
  57. package/package.json +21 -3
  58. package/src/api-types.ts +161 -0
  59. package/src/app/app.ts +28 -0
  60. package/src/app/rooms.ts +7 -1
  61. package/src/app/routes.ts +223 -1
  62. package/src/app/workspace-files.ts +1485 -0
  63. package/src/console-api.ts +5 -0
  64. package/src/i18n/zh-tw.ts +185 -24
  65. package/src/options.ts +182 -5
  66. package/src/push/mutes.ts +68 -0
  67. package/src/push/quiet-hours.ts +60 -0
  68. package/src/push/triggers.ts +40 -9
  69. package/src/web-plugin.ts +46 -2
  70. package/dist/chunk-character-creator-660d2t6p.js +0 -1
  71. package/dist/chunk-connectors-d19v8cjc.js +0 -1
  72. package/dist/chunk-conversations-4cmexmqq.js +0 -1
  73. package/dist/chunk-entry-8xpx35ef.js +0 -1
  74. package/dist/chunk-index-19g8jmr8.js +0 -2
  75. package/dist/chunk-index-362dfedj.js +0 -1
  76. package/dist/chunk-index-6g69tqt0.js +0 -47
  77. package/dist/chunk-index-89q2qdkj.css +0 -1
  78. package/dist/chunk-index-9tvf6es7.js +0 -41
  79. package/dist/chunk-index-p8h0e861.js +0 -2
  80. package/dist/chunk-index-xw5hhmfq.js +0 -2
  81. package/dist/chunk-notes-fefwds14.js +0 -1
  82. package/dist/chunk-overview-bxpwpmn6.js +0 -1
  83. package/dist/chunk-skills-pce3zgxy.js +0 -1
  84. package/dist/chunk-table-zmpbrfvz.js +0 -1
  85. package/dist/chunk-table2d-ac2gez71.js +0 -1
  86. package/dist/chunk-table3d-g258jemr.js +0 -4229
  87. package/dist/chunk-transcript-yhtjp3pd.js +0 -1
  88. package/dist/chunk-zh-tw-anqhqpaz.js +0 -1
package/src/app/routes.ts CHANGED
@@ -26,9 +26,16 @@ import type {
26
26
  SchedulesView,
27
27
  ScheduleView,
28
28
  SessionView,
29
+ WorkspaceConflict,
30
+ WorkspaceNew,
31
+ WorkspaceRefusalBody,
32
+ WorkspaceRename,
33
+ WorkspaceRootsView,
34
+ WorkspaceSave,
29
35
  } from "../api-types.ts";
30
36
  import type { Authenticated } from "../auth/authenticator.ts";
31
37
  import { json } from "../http.ts";
38
+ import type { PushMutes } from "../push/mutes.ts";
32
39
  import { readLimited } from "../request-body.ts";
33
40
  import type { CardLog } from "./card-log.ts";
34
41
  import { type FileDir, fileResponse, isFileId } from "./files.ts";
@@ -36,6 +43,12 @@ import { PAGE_LIMIT, type RoomLog } from "./room-log.ts";
36
43
  import type { Rooms } from "./rooms.ts";
37
44
  import type { AppSurface } from "./surface.ts";
38
45
  import { UploadRefusal, type Uploads } from "./uploads.ts";
46
+ import {
47
+ EDIT_MAX_BYTES,
48
+ WorkspaceConflictError,
49
+ type WorkspaceFiles,
50
+ WorkspaceRefusal,
51
+ } from "./workspace-files.ts";
39
52
 
40
53
  /** The largest JSON body a write reads: 64 KiB. */
41
54
  const MAX_BODY_BYTES = 64 * 1024;
@@ -51,6 +64,8 @@ export interface AppApiOptions {
51
64
  /** Where stored files are read from. */
52
65
  files: FileDir;
53
66
  modes: Pick<ConversationModes, "set" | "peek">;
67
+ /** The rooms whose push the owners muted. */
68
+ mutes: Pick<PushMutes, "set">;
54
69
  conversations: Pick<ConversationPort, "startFresh">;
55
70
  identity: Pick<IdentityService, "tierOf">;
56
71
  /** Present when the schedules pane is served. */
@@ -61,10 +76,14 @@ export interface AppApiOptions {
61
76
  "update" | "dress" | "usableModels" | "modelOf" | "defaultModel"
62
77
  >;
63
78
  avatars: Pick<AvatarStudio, "serve">;
79
+ /** Present when the workspace pane is served. */
80
+ workspace?: WorkspaceFiles;
64
81
  /** The mount, which a room's agent picture hangs under. */
65
82
  mount: string;
66
83
  /** How the console signs the person in, as the session reports it. */
67
84
  authMode: SessionView["auth"];
85
+ /** The quiet hours of push, as the settings page shows them. */
86
+ quietHours?: NonNullable<SessionView["quietHours"]>;
68
87
  /** Called after a change the other pages should refetch. */
69
88
  changed(): void;
70
89
  logger: Logger;
@@ -83,6 +102,78 @@ const bad = (message: string) => new HttpError(400, message);
83
102
  const notFound = () =>
84
103
  new HttpError(404, "There is no such room, agent or file.");
85
104
 
105
+ /** A refusal of the workspace pane as the HTTP error the owner reads. */
106
+ function refusalOf(refusal: WorkspaceRefusal): HttpError {
107
+ const { status } = refusal;
108
+ switch (refusal.reason) {
109
+ case "bad-path":
110
+ return new HttpError(status, "That path is not allowed.");
111
+ case "unknown-root":
112
+ return new HttpError(status, "There is no such place.");
113
+ case "not-found":
114
+ return new HttpError(status, "There is no such file or folder.");
115
+ case "not-a-dir":
116
+ return new HttpError(status, "That is not a folder.");
117
+ case "is-a-dir":
118
+ return new HttpError(status, "That is a folder, not a file.");
119
+ case "is-root":
120
+ return new HttpError(
121
+ status,
122
+ "That is the top folder, which cannot be changed.",
123
+ );
124
+ case "outside":
125
+ return new HttpError(status, "That points outside the workspace.");
126
+ case "special":
127
+ return new HttpError(status, "That is not a regular file.");
128
+ case "symlink":
129
+ return new HttpError(
130
+ status,
131
+ "That is a link; open the file it points to.",
132
+ );
133
+ case "read-only-root":
134
+ return new HttpError(status, "This place is read-only.");
135
+ case "git-dir":
136
+ return new HttpError(status, "Files inside .git are read-only.");
137
+ case "trash-dir":
138
+ return new HttpError(status, "The trash cannot be changed this way.");
139
+ case "binary":
140
+ return new HttpError(
141
+ status,
142
+ "This file is not text, so it cannot be edited.",
143
+ );
144
+ case "not-utf8":
145
+ return new HttpError(
146
+ status,
147
+ "This file is not UTF-8 text, so it cannot be edited.",
148
+ );
149
+ case "too-large":
150
+ return new HttpError(status, "This file is too large.");
151
+ case "text-too-large":
152
+ return new HttpError(
153
+ status,
154
+ "The text is too large to save (1 MiB at most).",
155
+ );
156
+ case "into-itself":
157
+ return new HttpError(status, "A folder cannot be moved into itself.");
158
+ case "cross-device":
159
+ return new HttpError(status, "That cannot be moved to another disk.");
160
+ case "permission":
161
+ return new HttpError(status, "The server is not allowed to change that.");
162
+ }
163
+ }
164
+
165
+ /** A conflict of the workspace pane as the HTTP error the owner reads. */
166
+ function conflictOf(conflict: WorkspaceConflictError): HttpError {
167
+ switch (conflict.conflict) {
168
+ case "changed":
169
+ return new HttpError(409, "This file changed while you were editing.");
170
+ case "gone":
171
+ return new HttpError(409, "This file is gone.");
172
+ case "exists":
173
+ return new HttpError(409, "Something with that name is already there.");
174
+ }
175
+ }
176
+
86
177
  /** The parts of a path under the mount, each decoded; undefined for one that is not valid. */
87
178
  export function partsOf(path: string): string[] | undefined {
88
179
  try {
@@ -152,6 +243,7 @@ export class AppApi {
152
243
  "schedules",
153
244
  "agents",
154
245
  "models",
246
+ "workspace",
155
247
  ].includes(second ?? ""));
156
248
  if (!mine) return undefined;
157
249
  try {
@@ -227,6 +319,7 @@ export class AppApi {
227
319
  }
228
320
  if (area === "files" && method === "GET" && id && !action)
229
321
  return this.#file(id);
322
+ if (area === "workspace") return this.#workspace(request, parts, admission);
230
323
  if (area === "schedules") return this.#schedules(method, id, admission);
231
324
  if (area === "agents" && id)
232
325
  return this.#agent(request, id, action, admission);
@@ -240,6 +333,9 @@ export class AppApi {
240
333
  surface: this.#options.surface.surface,
241
334
  auth: this.#options.authMode,
242
335
  push: true,
336
+ ...(this.#options.quietHours
337
+ ? { quietHours: this.#options.quietHours }
338
+ : {}),
243
339
  ...(admission.expiresAt
244
340
  ? { expiresAt: admission.expiresAt.toISOString() }
245
341
  : {}),
@@ -253,7 +349,7 @@ export class AppApi {
253
349
  ): Promise<Response> {
254
350
  const { method } = request;
255
351
  const [, , key, action] = parts;
256
- const { rooms, log, cards, modes, conversations } = this.#options;
352
+ const { rooms, log, cards, modes, mutes, conversations } = this.#options;
257
353
  if (!key) {
258
354
  if (method !== "GET") throw new HttpError(405, "Method not allowed.");
259
355
  return json({ rooms: await rooms.list() } satisfies RoomsView);
@@ -308,6 +404,17 @@ export class AppApi {
308
404
  await conversations.startFresh(room.key);
309
405
  return json({ ok: true });
310
406
  }
407
+ if (action === "mute" && method === "PUT" && parts.length === 4) {
408
+ // Only an agent's or group's room has a mute of its own; a thread's posts are its parent's business.
409
+ if (room.readOnly) throw notFound();
410
+ const owner = await this.#owner(admission);
411
+ const { muted } = await bodyOf(request);
412
+ if (typeof muted !== "boolean") throw bad("muted must be true or false.");
413
+ await mutes.set(room.key, muted, owner.principalId);
414
+ // The other pages and devices read the room list again to show the bell.
415
+ this.#options.changed();
416
+ return json({ muted });
417
+ }
311
418
  throw new HttpError(404, "There is no such API.");
312
419
  }
313
420
 
@@ -319,6 +426,121 @@ export class AppApi {
319
426
  return fileResponse(meta, blob);
320
427
  }
321
428
 
429
+ /** The workspace files pane's API; every call, a read too, is an owner's. */
430
+ async #workspace(
431
+ request: Request,
432
+ parts: string[],
433
+ admission: Authenticated,
434
+ ): Promise<Response> {
435
+ const files = this.#options.workspace;
436
+ if (!files) throw new HttpError(404, "This pane is not served.");
437
+ const owner = await this.#owner(admission);
438
+ return files.speaking(owner.id, () =>
439
+ this.#workspaceCall(request, parts, files, owner.id),
440
+ );
441
+ }
442
+
443
+ async #workspaceCall(
444
+ request: Request,
445
+ parts: string[],
446
+ files: WorkspaceFiles,
447
+ by: string,
448
+ ): Promise<Response> {
449
+ const { method } = request;
450
+ const [, , rootId, action, trashId, trashAction] = parts;
451
+ const params = new URL(request.url).searchParams;
452
+ const path = params.get("path") ?? "";
453
+ try {
454
+ if (!rootId) {
455
+ if (method !== "GET") throw new HttpError(405, "Method not allowed.");
456
+ return json({ roots: files.roots() } satisfies WorkspaceRootsView);
457
+ }
458
+ if (action === "list" && method === "GET" && parts.length === 4)
459
+ return json(await files.list(rootId, path));
460
+ if (action === "raw" && method === "GET" && parts.length === 4)
461
+ return await files.raw(rootId, path, params.get("download") === "1", {
462
+ range: request.headers.get("range"),
463
+ ifRange: request.headers.get("if-range"),
464
+ ifNoneMatch: request.headers.get("if-none-match"),
465
+ });
466
+ if (action === "file" && parts.length === 4) {
467
+ if (method === "GET") return json(await files.read(rootId, path));
468
+ if (method === "PUT") {
469
+ const body = await bodyOf(request, EDIT_MAX_BYTES * 2 + 4096);
470
+ const { text, baseHash } = body;
471
+ if (typeof text !== "string") throw bad("text must be text.");
472
+ if (typeof baseHash !== "string" || !/^[0-9a-f]{64}$/.test(baseHash))
473
+ throw bad("baseHash must be the hash the file was read with.");
474
+ const save: WorkspaceSave = { text, baseHash };
475
+ return json(await files.save(rootId, path, save, by));
476
+ }
477
+ }
478
+ if (action === "new" && method === "POST" && parts.length === 4) {
479
+ const body = await bodyOf(request, EDIT_MAX_BYTES * 2 + 4096);
480
+ const { kind, text } = body;
481
+ if (kind !== "file" && kind !== "dir")
482
+ throw bad("kind must be file or dir.");
483
+ if (text !== undefined && typeof text !== "string")
484
+ throw bad("text must be text.");
485
+ const made: WorkspaceNew = {
486
+ kind,
487
+ ...(text === undefined ? {} : { text }),
488
+ };
489
+ return json(await files.create(rootId, path, made, by), 201);
490
+ }
491
+ if (action === "rename" && method === "POST" && parts.length === 4) {
492
+ const body = await bodyOf(request);
493
+ const { from, to } = body;
494
+ if (typeof from !== "string" || typeof to !== "string")
495
+ throw bad("from and to must be paths.");
496
+ const moved: WorkspaceRename = { from, to };
497
+ await files.rename(rootId, moved.from, moved.to, by);
498
+ return json({ ok: true });
499
+ }
500
+ if (action === "entry" && method === "DELETE" && parts.length === 4)
501
+ return json(await files.remove(rootId, path, by));
502
+ if (action === "trash") {
503
+ if (method === "GET" && !trashId)
504
+ return json(await files.trash(rootId));
505
+ if (
506
+ method === "POST" &&
507
+ trashId &&
508
+ trashAction === "restore" &&
509
+ parts.length === 6
510
+ )
511
+ return json(await files.restore(rootId, trashId, by));
512
+ if (method === "DELETE" && trashId && !trashAction) {
513
+ await files.purge(rootId, trashId, by);
514
+ return json({ ok: true });
515
+ }
516
+ }
517
+ } catch (error) {
518
+ if (error instanceof WorkspaceRefusal) {
519
+ const refused = refusalOf(error);
520
+ return json(
521
+ {
522
+ error: refused.message,
523
+ reason: error.reason,
524
+ } satisfies WorkspaceRefusalBody,
525
+ refused.status,
526
+ );
527
+ }
528
+ if (error instanceof WorkspaceConflictError) {
529
+ const refused = conflictOf(error);
530
+ return json(
531
+ {
532
+ error: refused.message,
533
+ conflict: error.conflict,
534
+ ...(error.current ? { current: error.current } : {}),
535
+ } satisfies WorkspaceConflict,
536
+ 409,
537
+ );
538
+ }
539
+ throw error;
540
+ }
541
+ throw new HttpError(404, "There is no such API.");
542
+ }
543
+
322
544
  async #schedules(
323
545
  method: string,
324
546
  id: string | undefined,