pi-roundtable-web 0.22.5 → 0.23.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/CHANGELOG.md +53 -1
- package/README.md +444 -104
- package/assets/icons/192.png +0 -0
- package/assets/icons/512.png +0 -0
- package/assets/icons/apple-180.png +0 -0
- package/assets/icons/badge-96.png +0 -0
- package/assets/icons/mark-maskable.svg +1 -0
- package/assets/icons/mark.svg +1 -0
- package/assets/icons/maskable-512.png +0 -0
- package/dist/chunk-character-creator-660d2t6p.js +1 -0
- package/dist/chunk-connectors-d19v8cjc.js +1 -0
- package/dist/chunk-conversations-4cmexmqq.js +1 -0
- package/dist/chunk-entry-8xpx35ef.js +1 -0
- package/dist/chunk-index-19g8jmr8.js +2 -0
- package/dist/chunk-index-362dfedj.js +1 -0
- package/dist/chunk-index-6g69tqt0.js +47 -0
- package/dist/chunk-index-89q2qdkj.css +1 -0
- package/dist/chunk-index-9tvf6es7.js +41 -0
- package/dist/chunk-index-p8h0e861.js +2 -0
- package/dist/chunk-index-xw5hhmfq.js +2 -0
- package/dist/chunk-notes-fefwds14.js +1 -0
- package/dist/chunk-overview-bxpwpmn6.js +1 -0
- package/dist/chunk-skills-pce3zgxy.js +1 -0
- package/dist/chunk-table-zmpbrfvz.js +1 -0
- package/dist/chunk-table2d-ac2gez71.js +1 -0
- package/dist/chunk-table3d-g258jemr.js +4229 -0
- package/dist/chunk-transcript-yhtjp3pd.js +1 -0
- package/dist/chunk-zh-tw-anqhqpaz.js +1 -0
- package/dist/index.html +6 -2
- package/dist/sw.js +1 -0
- package/package.json +8 -3
- package/src/api-types.ts +448 -1
- package/src/app/app.ts +351 -0
- package/src/app/card-log.ts +119 -0
- package/src/app/desk.ts +533 -0
- package/src/app/files.ts +262 -0
- package/src/app/frames.ts +132 -0
- package/src/app/multipart.ts +172 -0
- package/src/app/presence.ts +73 -0
- package/src/app/protocol.ts +144 -0
- package/src/app/room-log.ts +137 -0
- package/src/app/rooms.ts +189 -0
- package/src/app/routes.ts +434 -0
- package/src/app/socket.ts +406 -0
- package/src/app/sockets.ts +84 -0
- package/src/app/surface-threads.ts +92 -0
- package/src/app/surface.ts +384 -0
- package/src/app/threads.ts +99 -0
- package/src/app/uploads.ts +363 -0
- package/src/assets.ts +1 -3
- package/src/auth/authenticator.ts +167 -0
- package/src/auth/login-page.ts +44 -0
- package/src/auth/none.ts +38 -0
- package/src/auth/password.ts +250 -0
- package/src/auth/session-key.ts +65 -0
- package/src/cleanup-api.ts +1 -1
- package/src/cloudflare-access.ts +5 -2
- package/src/console-api.ts +13 -15
- package/src/console-server.ts +77 -49
- package/src/features.ts +6 -1
- package/src/http.ts +8 -0
- package/src/i18n/index.ts +13 -0
- package/src/i18n/locale.ts +11 -0
- package/src/i18n/zh-tw.ts +696 -0
- package/src/index.ts +1 -1
- package/src/options.ts +308 -12
- package/src/page-colors.ts +9 -0
- package/src/page-presentation.ts +17 -12
- package/src/push/api.ts +190 -0
- package/src/push/channel.ts +50 -0
- package/src/push/encrypt.ts +136 -0
- package/src/push/endpoint.ts +54 -0
- package/src/push/sender.ts +109 -0
- package/src/push/subscriptions.ts +140 -0
- package/src/push/test-fetch.ts +17 -0
- package/src/push/triggers.ts +155 -0
- package/src/push/vapid.ts +124 -0
- package/src/pwa.ts +130 -0
- package/src/request-body.ts +20 -0
- package/src/verifier.ts +7 -1
- package/src/web-plugin.ts +123 -11
- package/dist/chunk-82frzte9.js +0 -36
- package/dist/chunk-e8z9q554.css +0 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { QUESTIONNAIRE_LIMITS } from "pi-roundtable";
|
|
2
|
+
import {
|
|
3
|
+
type ClientFrame,
|
|
4
|
+
MAX_MESSAGE_FILES,
|
|
5
|
+
type PromptAnswer,
|
|
6
|
+
} from "../api-types.ts";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
APP_CLOSE_EXPIRED,
|
|
10
|
+
APP_PROTOCOL,
|
|
11
|
+
APP_PROTOCOL_VERSION,
|
|
12
|
+
APPROVAL_MAX_BYTES,
|
|
13
|
+
type ClientFrame,
|
|
14
|
+
MAX_MESSAGE_CHARS,
|
|
15
|
+
MAX_MESSAGE_FILES,
|
|
16
|
+
type ServerFrame,
|
|
17
|
+
} from "../api-types.ts";
|
|
18
|
+
|
|
19
|
+
/** The longest room key, prompt id, upload id or client reference accepted. */
|
|
20
|
+
const ID_CHARS = 200;
|
|
21
|
+
|
|
22
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
23
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
|
|
25
|
+
const isId = (value: unknown): value is string =>
|
|
26
|
+
typeof value === "string" && value.length > 0 && value.length <= ID_CHARS;
|
|
27
|
+
|
|
28
|
+
const isText = (value: unknown): value is string => typeof value === "string";
|
|
29
|
+
|
|
30
|
+
const optional = <T>(value: unknown, check: (v: unknown) => v is T) =>
|
|
31
|
+
value === undefined || check(value);
|
|
32
|
+
|
|
33
|
+
function parseAnswer(value: unknown): PromptAnswer | undefined {
|
|
34
|
+
if (!isRecord(value) || !Array.isArray(value.choices)) return undefined;
|
|
35
|
+
const { choices, text } = value;
|
|
36
|
+
if (!choices.every(isText) || !optional(text, isText)) return undefined;
|
|
37
|
+
return {
|
|
38
|
+
choices: choices as string[],
|
|
39
|
+
...(text === undefined ? {} : { text }),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseAnswers(value: Record<string, unknown>): ClientFrame | undefined {
|
|
44
|
+
const { prompt, answers, note } = value;
|
|
45
|
+
if (!isId(prompt) || !Array.isArray(answers)) return undefined;
|
|
46
|
+
if (answers.length < 1 || answers.length > QUESTIONNAIRE_LIMITS.questions)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (!optional(note, isText)) return undefined;
|
|
49
|
+
const parsed: (PromptAnswer | null)[] = [];
|
|
50
|
+
for (const answer of answers as unknown[]) {
|
|
51
|
+
if (answer === null) {
|
|
52
|
+
parsed.push(null);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const one = parseAnswer(answer);
|
|
56
|
+
if (!one) return undefined;
|
|
57
|
+
parsed.push(one);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
type: "answers",
|
|
61
|
+
prompt,
|
|
62
|
+
answers: parsed,
|
|
63
|
+
...(note === undefined ? {} : { note }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One client frame, checked field by field; undefined for anything else. */
|
|
68
|
+
export function parseClientFrame(
|
|
69
|
+
raw: string | Uint8Array,
|
|
70
|
+
): ClientFrame | undefined {
|
|
71
|
+
let value: unknown;
|
|
72
|
+
try {
|
|
73
|
+
value = JSON.parse(
|
|
74
|
+
typeof raw === "string" ? raw : new TextDecoder().decode(raw),
|
|
75
|
+
);
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
if (!isRecord(value)) return undefined;
|
|
80
|
+
switch (value.type) {
|
|
81
|
+
case "hello": {
|
|
82
|
+
const { visible, focus } = value;
|
|
83
|
+
if (typeof visible !== "boolean" || !optional(focus, isId))
|
|
84
|
+
return undefined;
|
|
85
|
+
return {
|
|
86
|
+
type: "hello",
|
|
87
|
+
visible,
|
|
88
|
+
...(focus === undefined ? {} : { focus }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
case "visibility":
|
|
92
|
+
return typeof value.visible === "boolean"
|
|
93
|
+
? { type: "visibility", visible: value.visible }
|
|
94
|
+
: undefined;
|
|
95
|
+
case "focus":
|
|
96
|
+
if (!optional(value.room, isId)) return undefined;
|
|
97
|
+
return {
|
|
98
|
+
type: "focus",
|
|
99
|
+
...(value.room === undefined ? {} : { room: value.room as string }),
|
|
100
|
+
};
|
|
101
|
+
case "send": {
|
|
102
|
+
const { id, room, text, uploads } = value;
|
|
103
|
+
if (!isId(id) || !isId(room) || !isText(text)) return undefined;
|
|
104
|
+
if (
|
|
105
|
+
uploads !== undefined &&
|
|
106
|
+
!(
|
|
107
|
+
Array.isArray(uploads) &&
|
|
108
|
+
uploads.length <= MAX_MESSAGE_FILES &&
|
|
109
|
+
uploads.every(isId)
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return undefined;
|
|
113
|
+
return {
|
|
114
|
+
type: "send",
|
|
115
|
+
id,
|
|
116
|
+
room,
|
|
117
|
+
text,
|
|
118
|
+
...(uploads === undefined || uploads.length === 0
|
|
119
|
+
? {}
|
|
120
|
+
: { uploads: uploads as string[] }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
case "stop":
|
|
124
|
+
return isId(value.room) ? { type: "stop", room: value.room } : undefined;
|
|
125
|
+
case "approval":
|
|
126
|
+
return isId(value.prompt) && typeof value.approved === "boolean"
|
|
127
|
+
? { type: "approval", prompt: value.prompt, approved: value.approved }
|
|
128
|
+
: undefined;
|
|
129
|
+
case "answer": {
|
|
130
|
+
const answer = parseAnswer(value.answer);
|
|
131
|
+
return isId(value.prompt) && answer
|
|
132
|
+
? { type: "answer", prompt: value.prompt, answer }
|
|
133
|
+
: undefined;
|
|
134
|
+
}
|
|
135
|
+
case "answers":
|
|
136
|
+
return parseAnswers(value);
|
|
137
|
+
case "cancel":
|
|
138
|
+
return isId(value.prompt)
|
|
139
|
+
? { type: "cancel", prompt: value.prompt }
|
|
140
|
+
: undefined;
|
|
141
|
+
default:
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "pi-roundtable";
|
|
3
|
+
import type { RoomFile, RoomMessage } from "../api-types.ts";
|
|
4
|
+
|
|
5
|
+
/** A message to store; the log numbers it and stamps its time. */
|
|
6
|
+
export interface NewRoomMessage {
|
|
7
|
+
room: string;
|
|
8
|
+
kind: RoomMessage["kind"];
|
|
9
|
+
principalId?: string;
|
|
10
|
+
agent?: string;
|
|
11
|
+
author: string;
|
|
12
|
+
text: string;
|
|
13
|
+
files?: RoomFile[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Row {
|
|
17
|
+
id: string | number | bigint;
|
|
18
|
+
room: string;
|
|
19
|
+
kind: RoomMessage["kind"];
|
|
20
|
+
principal_id: string | null;
|
|
21
|
+
agent: string | null;
|
|
22
|
+
author_name: string;
|
|
23
|
+
text: string;
|
|
24
|
+
files: RoomFile[] | string;
|
|
25
|
+
created_at: Date;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The most messages one page of history holds. */
|
|
29
|
+
export const PAGE_LIMIT = 100;
|
|
30
|
+
|
|
31
|
+
function messageOf(row: Row): RoomMessage {
|
|
32
|
+
const files =
|
|
33
|
+
typeof row.files === "string"
|
|
34
|
+
? (JSON.parse(row.files) as RoomFile[])
|
|
35
|
+
: row.files;
|
|
36
|
+
return {
|
|
37
|
+
id: Number(row.id),
|
|
38
|
+
room: row.room,
|
|
39
|
+
kind: row.kind,
|
|
40
|
+
...(row.principal_id === null ? {} : { principalId: row.principal_id }),
|
|
41
|
+
...(row.agent === null ? {} : { agent: row.agent }),
|
|
42
|
+
author: row.author_name,
|
|
43
|
+
text: row.text,
|
|
44
|
+
files,
|
|
45
|
+
at: row.created_at.toISOString(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The app's rooms' messages, kept in `web_room_messages`: what the owner wrote, what the agents
|
|
51
|
+
* answered, and the lines the server wrote, such as a conversation's start-over divider. Rooms
|
|
52
|
+
* are separate from the Discord channels' history, which the conversations pane reads as
|
|
53
|
+
* transcripts.
|
|
54
|
+
*/
|
|
55
|
+
export class RoomLog {
|
|
56
|
+
readonly #sql: SQL;
|
|
57
|
+
|
|
58
|
+
constructor(sql: SQL) {
|
|
59
|
+
this.#sql = sql;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The log's table. */
|
|
63
|
+
static readonly migration: Migration = {
|
|
64
|
+
name: "web-room-messages",
|
|
65
|
+
up: async (sql) => {
|
|
66
|
+
await sql`
|
|
67
|
+
CREATE TABLE IF NOT EXISTS web_room_messages (
|
|
68
|
+
id bigserial PRIMARY KEY,
|
|
69
|
+
room text NOT NULL,
|
|
70
|
+
kind text NOT NULL,
|
|
71
|
+
principal_id text,
|
|
72
|
+
agent text,
|
|
73
|
+
author_name text NOT NULL,
|
|
74
|
+
text text NOT NULL,
|
|
75
|
+
files jsonb NOT NULL DEFAULT '[]',
|
|
76
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
77
|
+
)`;
|
|
78
|
+
await sql`CREATE INDEX IF NOT EXISTS web_room_messages_room_id ON web_room_messages (room, id)`;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
async append(message: NewRoomMessage): Promise<RoomMessage> {
|
|
83
|
+
const files = JSON.stringify(message.files ?? []);
|
|
84
|
+
const [row]: Row[] = await this.#sql`
|
|
85
|
+
INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files)
|
|
86
|
+
VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
|
|
87
|
+
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb)
|
|
88
|
+
RETURNING *`;
|
|
89
|
+
if (!row) throw new Error("the room log stored nothing");
|
|
90
|
+
return messageOf(row);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Changes the text of a message stored in the room; undefined when the room holds no such message. */
|
|
94
|
+
async edit(
|
|
95
|
+
room: string,
|
|
96
|
+
id: number,
|
|
97
|
+
text: string,
|
|
98
|
+
): Promise<RoomMessage | undefined> {
|
|
99
|
+
const [row]: Row[] = await this.#sql`
|
|
100
|
+
UPDATE web_room_messages SET text = ${text}
|
|
101
|
+
WHERE id = ${id} AND room = ${room} RETURNING *`;
|
|
102
|
+
return row && messageOf(row);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The newest `limit` messages older than `before` (all of them when absent), oldest first. */
|
|
106
|
+
async page(
|
|
107
|
+
room: string,
|
|
108
|
+
before: number | undefined,
|
|
109
|
+
limit: number,
|
|
110
|
+
): Promise<{ messages: RoomMessage[]; more: boolean }> {
|
|
111
|
+
const count = Math.min(Math.max(Math.trunc(limit), 1), PAGE_LIMIT);
|
|
112
|
+
const rows: Row[] =
|
|
113
|
+
before === undefined
|
|
114
|
+
? await this.#sql`
|
|
115
|
+
SELECT * FROM web_room_messages WHERE room = ${room}
|
|
116
|
+
ORDER BY id DESC LIMIT ${count + 1}`
|
|
117
|
+
: await this.#sql`
|
|
118
|
+
SELECT * FROM web_room_messages WHERE room = ${room} AND id < ${before}
|
|
119
|
+
ORDER BY id DESC LIMIT ${count + 1}`;
|
|
120
|
+
return {
|
|
121
|
+
messages: rows.slice(0, count).map(messageOf).reverse(),
|
|
122
|
+
more: rows.length > count,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The newest message of each of the rooms that has one. */
|
|
127
|
+
async lastOf(rooms: readonly string[]): Promise<Map<string, RoomMessage>> {
|
|
128
|
+
const found = new Map<string, RoomMessage>();
|
|
129
|
+
if (rooms.length === 0) return found;
|
|
130
|
+
const rows: Row[] = await this.#sql`
|
|
131
|
+
SELECT DISTINCT ON (room) * FROM web_room_messages
|
|
132
|
+
WHERE room IN ${this.#sql(rooms)}
|
|
133
|
+
ORDER BY room, id DESC`;
|
|
134
|
+
for (const row of rows) found.set(row.room, messageOf(row));
|
|
135
|
+
return found;
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/app/rooms.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Agent,
|
|
3
|
+
AgentDirectory,
|
|
4
|
+
AgentGroup,
|
|
5
|
+
AgentTeam,
|
|
6
|
+
AvatarStudio,
|
|
7
|
+
ChannelKey,
|
|
8
|
+
ConversationModes,
|
|
9
|
+
} from "pi-roundtable";
|
|
10
|
+
import {
|
|
11
|
+
agentRoomKey,
|
|
12
|
+
channelKey,
|
|
13
|
+
characterOf,
|
|
14
|
+
groupRoomKey,
|
|
15
|
+
roomOf,
|
|
16
|
+
} from "pi-roundtable";
|
|
17
|
+
import type { RoomKind, RoomView } from "../api-types.ts";
|
|
18
|
+
import type { RoomLog } from "./room-log.ts";
|
|
19
|
+
import type { WebThreads } from "./threads.ts";
|
|
20
|
+
|
|
21
|
+
/** A room of the app that exists: what it is, and what it is called. */
|
|
22
|
+
export interface ResolvedRoom {
|
|
23
|
+
key: ChannelKey;
|
|
24
|
+
kind: RoomKind;
|
|
25
|
+
/** The agent's or group's name; a thread's `thread.<uuid>`. */
|
|
26
|
+
name: string;
|
|
27
|
+
displayName: string;
|
|
28
|
+
/** A thread's room is read only. */
|
|
29
|
+
readOnly: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RoomsOptions {
|
|
33
|
+
surface: string;
|
|
34
|
+
/** The mount, whose `avatars/` serves the pictures. */
|
|
35
|
+
mount: string;
|
|
36
|
+
directory: Pick<AgentDirectory, "agents" | "agent" | "groups" | "group">;
|
|
37
|
+
team: Pick<AgentTeam, "coordinator">;
|
|
38
|
+
avatars: Pick<AvatarStudio, "url">;
|
|
39
|
+
threads: Pick<WebThreads, "get" | "list">;
|
|
40
|
+
log: Pick<RoomLog, "lastOf">;
|
|
41
|
+
modes: Pick<ConversationModes, "peek">;
|
|
42
|
+
activity(
|
|
43
|
+
room: ChannelKey,
|
|
44
|
+
): Pick<RoomView, "typing" | "stoppable" | "phase" | "agent">;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const THREAD_ID = /^thread\.[0-9a-f-]{36}$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The rooms of the app: Home (the coordinator's), one per active agent, one per active group,
|
|
51
|
+
* and the dispatch threads: the open ones and the latest closed ones. A key a client names is a room only when it is one of these on
|
|
52
|
+
* this surface, whose agent or group exists; no other surface's key ever is.
|
|
53
|
+
*/
|
|
54
|
+
export class Rooms {
|
|
55
|
+
readonly #options: RoomsOptions;
|
|
56
|
+
|
|
57
|
+
constructor(options: RoomsOptions) {
|
|
58
|
+
this.#options = options;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether the key is on this surface, whatever it names. */
|
|
62
|
+
ownsKey(key: string): boolean {
|
|
63
|
+
return key.startsWith(`${this.#options.surface}:`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The room a key names, or undefined when it is none of the app's. */
|
|
67
|
+
async resolve(key: string): Promise<ResolvedRoom | undefined> {
|
|
68
|
+
if (!this.ownsKey(key)) return undefined;
|
|
69
|
+
const { surface, directory, threads } = this.#options;
|
|
70
|
+
const room = key as ChannelKey;
|
|
71
|
+
const named = roomOf(room, [surface]);
|
|
72
|
+
if (named?.kind === "agent") {
|
|
73
|
+
const agent = directory.agent(named.name);
|
|
74
|
+
return agent?.status === "active"
|
|
75
|
+
? {
|
|
76
|
+
key: room,
|
|
77
|
+
kind: "agent",
|
|
78
|
+
name: agent.name,
|
|
79
|
+
displayName: agent.displayName,
|
|
80
|
+
readOnly: false,
|
|
81
|
+
}
|
|
82
|
+
: undefined;
|
|
83
|
+
}
|
|
84
|
+
if (named?.kind === "group") {
|
|
85
|
+
const group = directory.group(named.name);
|
|
86
|
+
return group?.status === "active"
|
|
87
|
+
? {
|
|
88
|
+
key: room,
|
|
89
|
+
kind: "group",
|
|
90
|
+
name: group.name,
|
|
91
|
+
displayName: group.displayName,
|
|
92
|
+
readOnly: false,
|
|
93
|
+
}
|
|
94
|
+
: undefined;
|
|
95
|
+
}
|
|
96
|
+
const id = key.slice(surface.length + 1);
|
|
97
|
+
if (!THREAD_ID.test(id)) return undefined;
|
|
98
|
+
const thread = await threads.get(id);
|
|
99
|
+
// A closed thread stays a room the page can read; nobody can write in it.
|
|
100
|
+
return thread
|
|
101
|
+
? {
|
|
102
|
+
key: room,
|
|
103
|
+
kind: "thread",
|
|
104
|
+
name: thread.id,
|
|
105
|
+
displayName: thread.title,
|
|
106
|
+
readOnly: true,
|
|
107
|
+
}
|
|
108
|
+
: undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The home room: the coordinator's, else the first active agent's. */
|
|
112
|
+
homeKey(): ChannelKey | undefined {
|
|
113
|
+
const { directory, team, surface } = this.#options;
|
|
114
|
+
const coordinator = team.coordinator();
|
|
115
|
+
const home =
|
|
116
|
+
(coordinator ? directory.agent(coordinator) : undefined) ??
|
|
117
|
+
directory.agents().find((agent) => agent.status === "active");
|
|
118
|
+
return home?.status === "active"
|
|
119
|
+
? agentRoomKey(surface, home.name)
|
|
120
|
+
: undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The agent whose avatar path to show, as a path under the mount. */
|
|
124
|
+
avatarOf(agent: Agent): string {
|
|
125
|
+
// The studio gives a full URL with a public address and a bare path without one.
|
|
126
|
+
const path = /\/avatars\/[^/?#]+$/.exec(
|
|
127
|
+
this.#options.avatars.url(agent.avatarHash),
|
|
128
|
+
)?.[0];
|
|
129
|
+
return `${this.#options.mount}${path ?? "/avatars/missing.png"}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async list(): Promise<RoomView[]> {
|
|
133
|
+
const { surface, directory, modes, activity, log, threads } = this.#options;
|
|
134
|
+
const home = this.homeKey();
|
|
135
|
+
const agents = directory.agents().filter((a) => a.status === "active");
|
|
136
|
+
const groups = directory.groups().filter((g) => g.status === "active");
|
|
137
|
+
const shown = await threads.list();
|
|
138
|
+
const view = (
|
|
139
|
+
key: ChannelKey,
|
|
140
|
+
rest: Pick<RoomView, "kind" | "name" | "displayName"> & Partial<RoomView>,
|
|
141
|
+
): RoomView => {
|
|
142
|
+
const { yolo, longRunning } = modes.peek(key);
|
|
143
|
+
return {
|
|
144
|
+
home: key === home,
|
|
145
|
+
readOnly: false,
|
|
146
|
+
mode: { yolo, longRunning },
|
|
147
|
+
...activity(key),
|
|
148
|
+
...rest,
|
|
149
|
+
key,
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
const rooms: RoomView[] = [
|
|
153
|
+
...agents.map((agent: Agent) =>
|
|
154
|
+
view(agentRoomKey(surface, agent.name), {
|
|
155
|
+
kind: "agent",
|
|
156
|
+
name: agent.name,
|
|
157
|
+
displayName: agent.displayName,
|
|
158
|
+
avatar: this.avatarOf(agent),
|
|
159
|
+
character: characterOf(agent),
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
...groups.map((group: AgentGroup) =>
|
|
163
|
+
view(groupRoomKey(surface, group.name), {
|
|
164
|
+
kind: "group",
|
|
165
|
+
name: group.name,
|
|
166
|
+
displayName: group.displayName,
|
|
167
|
+
members: [...group.members],
|
|
168
|
+
}),
|
|
169
|
+
),
|
|
170
|
+
...shown.map((thread) =>
|
|
171
|
+
view(channelKey(surface, thread.id), {
|
|
172
|
+
kind: "thread",
|
|
173
|
+
name: thread.id,
|
|
174
|
+
displayName: thread.title,
|
|
175
|
+
parent: thread.parent,
|
|
176
|
+
readOnly: true,
|
|
177
|
+
...(thread.open ? {} : { closed: true }),
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
];
|
|
181
|
+
// Home first; the others keep the team's order.
|
|
182
|
+
rooms.sort((a, b) => Number(b.home) - Number(a.home));
|
|
183
|
+
const last = await log.lastOf(rooms.map((room) => room.key));
|
|
184
|
+
return rooms.map((room) => {
|
|
185
|
+
const message = last.get(room.key);
|
|
186
|
+
return message ? { ...room, last: message } : room;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|