pi-roundtable-web 0.7.0 → 0.7.3
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 +10 -1
- package/README.md +58 -8
- package/dist/chunk-32sbjm56.js +36 -0
- package/dist/index.html +1 -1
- package/package.json +4 -2
- package/src/api-types.ts +66 -1
- package/src/cleanup-api.ts +54 -0
- package/src/connectors-api.ts +26 -0
- package/src/console-api.ts +108 -22
- package/src/console-server.ts +35 -11
- package/src/conversations.ts +27 -21
- package/src/features.ts +59 -0
- package/src/http.ts +11 -0
- package/src/index.ts +8 -0
- package/src/options.ts +32 -2
- package/src/page-presentation.ts +30 -0
- package/src/skills-api.ts +77 -0
- package/src/web-plugin.ts +14 -1
- package/dist/chunk-gkh6x5ag.js +0 -9
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Console</title>
|
|
7
7
|
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./chunk-e8z9q554.css"><script type="module" crossorigin src="./chunk-
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./chunk-e8z9q554.css"><script type="module" crossorigin src="./chunk-32sbjm56.js"></script></head>
|
|
9
9
|
<body>
|
|
10
10
|
<div id="root"></div>
|
|
11
11
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-web",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "An owner-only web console for pi-roundtable: conversations, transcripts, and memory notes, with live updates",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,9 +47,11 @@
|
|
|
47
47
|
"@types/bun": "1.4.2",
|
|
48
48
|
"@types/react": "19.3.0",
|
|
49
49
|
"@types/react-dom": "19.3.0",
|
|
50
|
-
"pi-roundtable": "0.7.
|
|
50
|
+
"pi-roundtable": "0.7.3",
|
|
51
51
|
"react": "19.3.0",
|
|
52
52
|
"react-dom": "19.3.0",
|
|
53
|
+
"react-markdown": "10.1.0",
|
|
54
|
+
"remark-gfm": "4.0.1",
|
|
53
55
|
"typescript": "7.0.2"
|
|
54
56
|
}
|
|
55
57
|
}
|
package/src/api-types.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// The console's API shapes, shared by the server (`src/`) and the page (`web/`). Times are ISO strings.
|
|
2
2
|
|
|
3
3
|
/** The panes the console can show; the options choose which ones a host serves. */
|
|
4
|
-
export const PANES = [
|
|
4
|
+
export const PANES = [
|
|
5
|
+
"overview",
|
|
6
|
+
"conversations",
|
|
7
|
+
"notes",
|
|
8
|
+
"skills",
|
|
9
|
+
"connectors",
|
|
10
|
+
] as const;
|
|
5
11
|
export type PaneName = (typeof PANES)[number];
|
|
6
12
|
|
|
7
13
|
/** What the page needs before it can draw anything. */
|
|
@@ -10,6 +16,12 @@ export interface ConfigView {
|
|
|
10
16
|
panes: PaneName[];
|
|
11
17
|
/** The IANA zone the page formats times and event dates in: the host's `env.timeZone`. */
|
|
12
18
|
timeZone: string;
|
|
19
|
+
locale?: string;
|
|
20
|
+
messages?: Readonly<Record<string, string>>;
|
|
21
|
+
cleanup?: boolean;
|
|
22
|
+
connectorAdminUrl?: string;
|
|
23
|
+
/** Only supplied for path-based routing; also the base for page/API URLs. */
|
|
24
|
+
mountPath?: string;
|
|
13
25
|
}
|
|
14
26
|
|
|
15
27
|
export type ChannelName =
|
|
@@ -53,6 +65,19 @@ export interface OverviewView {
|
|
|
53
65
|
guildId: string;
|
|
54
66
|
agents: AgentView[];
|
|
55
67
|
groups: GroupView[];
|
|
68
|
+
workspaces?: (ConversationView & { schedules: number })[];
|
|
69
|
+
outside?: ConversationView[];
|
|
70
|
+
party?: PartyView[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Compatibility dashboard shape for hosts migrating an existing console endpoint. */
|
|
74
|
+
export interface DashboardView {
|
|
75
|
+
agentGuildId: string;
|
|
76
|
+
agents: (AgentView & { key: string })[];
|
|
77
|
+
groups: (GroupView & { key: string })[];
|
|
78
|
+
workspaces: (ConversationView & { channelId: string; schedules: number })[];
|
|
79
|
+
outside: (ConversationView & { sessionId: string })[];
|
|
80
|
+
party: PartyView[];
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
/** Who a stored conversation belongs to. */
|
|
@@ -122,6 +147,46 @@ export interface NoteInput {
|
|
|
122
147
|
eventDate?: string;
|
|
123
148
|
}
|
|
124
149
|
|
|
150
|
+
export interface PartyView {
|
|
151
|
+
key: string;
|
|
152
|
+
channelId: string;
|
|
153
|
+
channel: ChannelName;
|
|
154
|
+
profile: string;
|
|
155
|
+
enabledBy: string;
|
|
156
|
+
enabledAt: string;
|
|
157
|
+
container: "missing" | "running" | "stopped" | "unknown";
|
|
158
|
+
busy: number;
|
|
159
|
+
lastActive?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface SkillView {
|
|
163
|
+
name: string;
|
|
164
|
+
source:
|
|
165
|
+
| { kind: "builtin" }
|
|
166
|
+
| { kind: "linked"; repo: string; path: string }
|
|
167
|
+
| { kind: "written" };
|
|
168
|
+
description?: string;
|
|
169
|
+
missing?: string;
|
|
170
|
+
groups: string[];
|
|
171
|
+
carriers: string[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface SkillDetailView {
|
|
175
|
+
name: string;
|
|
176
|
+
metadata: { key: string; value: string }[];
|
|
177
|
+
body: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface ConnectorsView {
|
|
181
|
+
gateways: {
|
|
182
|
+
name: string;
|
|
183
|
+
enabled: boolean;
|
|
184
|
+
reachable: boolean;
|
|
185
|
+
tools: number;
|
|
186
|
+
}[];
|
|
187
|
+
servers: { name: string; tools: string[]; usedBy: string[] }[];
|
|
188
|
+
}
|
|
189
|
+
|
|
125
190
|
/** Every error body the API returns. */
|
|
126
191
|
export interface ApiError {
|
|
127
192
|
error: string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { AgentTeam, ChannelKey } from "pi-roundtable";
|
|
3
|
+
import { conversationFiles, parseKey } from "./conversations.ts";
|
|
4
|
+
import type { ConsoleFeatures } from "./features.ts";
|
|
5
|
+
import { ConsoleHttpError as HttpError, json } from "./http.ts";
|
|
6
|
+
|
|
7
|
+
interface CleanupPorts {
|
|
8
|
+
features?: ConsoleFeatures;
|
|
9
|
+
exclude?: (key: ChannelKey) => boolean;
|
|
10
|
+
team?: Pick<AgentTeam, "owns" | "status">;
|
|
11
|
+
sessionsDir: string;
|
|
12
|
+
changed(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The console validates ownership before invoking destructive host operations. */
|
|
16
|
+
export async function cleanupConversation(
|
|
17
|
+
ports: CleanupPorts,
|
|
18
|
+
raw: string,
|
|
19
|
+
action: "start-over" | "delete",
|
|
20
|
+
): Promise<Response> {
|
|
21
|
+
const { features, exclude, team, sessionsDir } = ports;
|
|
22
|
+
const cleanup = features?.cleanup;
|
|
23
|
+
const key = raw as ChannelKey;
|
|
24
|
+
const parsed = parseKey(raw);
|
|
25
|
+
if (!cleanup || !parsed || parsed.kind === "group" || exclude?.(key))
|
|
26
|
+
throw new HttpError(404, "There is no such conversation.");
|
|
27
|
+
const owned =
|
|
28
|
+
Boolean(team?.owns(key)) || Boolean(features?.party?.contains(key));
|
|
29
|
+
const group =
|
|
30
|
+
(await team?.status())?.groups.some(
|
|
31
|
+
(g) => `discord:${g.channelId}` === key,
|
|
32
|
+
) ?? false;
|
|
33
|
+
const stored =
|
|
34
|
+
!owned &&
|
|
35
|
+
!group &&
|
|
36
|
+
Boolean(conversationFiles(join(sessionsDir, parsed.dir)));
|
|
37
|
+
if (action === "delete") {
|
|
38
|
+
if (!stored)
|
|
39
|
+
throw new HttpError(
|
|
40
|
+
404,
|
|
41
|
+
"Only workspace and outside conversations can be deleted.",
|
|
42
|
+
);
|
|
43
|
+
const result = await cleanup.deleteConversation(key);
|
|
44
|
+
if (result === "busy")
|
|
45
|
+
throw new HttpError(409, "The conversation is busy. Try again later.");
|
|
46
|
+
ports.changed();
|
|
47
|
+
return json({ result });
|
|
48
|
+
}
|
|
49
|
+
if (!owned && !group && !stored)
|
|
50
|
+
throw new HttpError(404, "There is no such conversation.");
|
|
51
|
+
const kind = await cleanup.startFresh(key);
|
|
52
|
+
ports.changed();
|
|
53
|
+
return json({ kind });
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ConnectorsView } from "./api-types.ts";
|
|
2
|
+
import type { ConsoleFeatures } from "./features.ts";
|
|
3
|
+
import { ConsoleHttpError as HttpError } from "./http.ts";
|
|
4
|
+
|
|
5
|
+
export async function connectorsView(
|
|
6
|
+
connectors: NonNullable<ConsoleFeatures["connectors"]>,
|
|
7
|
+
): Promise<ConnectorsView> {
|
|
8
|
+
try {
|
|
9
|
+
const [gateways, servers] = await Promise.all([
|
|
10
|
+
connectors.gateways(),
|
|
11
|
+
connectors.servers(),
|
|
12
|
+
]);
|
|
13
|
+
return {
|
|
14
|
+
gateways,
|
|
15
|
+
servers: servers.map((server) => ({
|
|
16
|
+
...server,
|
|
17
|
+
usedBy: connectors.usedBy(server.name),
|
|
18
|
+
})),
|
|
19
|
+
};
|
|
20
|
+
} catch {
|
|
21
|
+
throw new HttpError(
|
|
22
|
+
502,
|
|
23
|
+
"The connector service did not respond. Try again later.",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/console-api.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
ConversationKind,
|
|
17
17
|
ConversationsView,
|
|
18
18
|
ConversationView,
|
|
19
|
+
DashboardView,
|
|
19
20
|
NoteInput,
|
|
20
21
|
NoteView,
|
|
21
22
|
OverviewView,
|
|
@@ -31,6 +32,13 @@ import {
|
|
|
31
32
|
storedConversations,
|
|
32
33
|
} from "./conversations.ts";
|
|
33
34
|
|
|
35
|
+
import {
|
|
36
|
+
type ConsoleFeatures,
|
|
37
|
+
type ConsolePresentation,
|
|
38
|
+
message,
|
|
39
|
+
safeAdminUrl,
|
|
40
|
+
} from "./features.ts";
|
|
41
|
+
|
|
34
42
|
const NOTE_SEARCH_LIMIT = 500;
|
|
35
43
|
/** The largest note body the console reads. */
|
|
36
44
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
@@ -58,22 +66,15 @@ export interface ConsolePorts {
|
|
|
58
66
|
/** Called after a note is written, so every open page refreshes. */
|
|
59
67
|
changed(): void;
|
|
60
68
|
logger: Logger;
|
|
69
|
+
features?: ConsoleFeatures;
|
|
70
|
+
presentation?: ConsolePresentation;
|
|
71
|
+
mountPath?: string;
|
|
61
72
|
}
|
|
62
73
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
) {
|
|
68
|
-
super(message);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const json = (body: unknown, status = 200) =>
|
|
73
|
-
Response.json(body, {
|
|
74
|
-
status,
|
|
75
|
-
headers: { "cache-control": "no-store" },
|
|
76
|
-
});
|
|
74
|
+
import { cleanupConversation } from "./cleanup-api.ts";
|
|
75
|
+
import { connectorsView } from "./connectors-api.ts";
|
|
76
|
+
import { ConsoleHttpError as HttpError, json } from "./http.ts";
|
|
77
|
+
import { skillCatalog, skillDetail } from "./skills-api.ts";
|
|
77
78
|
|
|
78
79
|
/** Memory refusals as the Notes pane shows them. */
|
|
79
80
|
const MEMORY_REASONS: Record<string, string> = {
|
|
@@ -105,14 +106,27 @@ export class ConsoleApi {
|
|
|
105
106
|
return await this.#route(request, path);
|
|
106
107
|
} catch (error) {
|
|
107
108
|
if (error instanceof HttpError)
|
|
108
|
-
return json(
|
|
109
|
+
return json(
|
|
110
|
+
{
|
|
111
|
+
error: message(this.#ports.presentation, error.message),
|
|
112
|
+
} satisfies ApiError,
|
|
113
|
+
error.status,
|
|
114
|
+
);
|
|
109
115
|
if (error instanceof MemoryError)
|
|
110
|
-
return json(
|
|
116
|
+
return json(
|
|
117
|
+
{
|
|
118
|
+
error: message(this.#ports.presentation, memoryReason(error)),
|
|
119
|
+
} satisfies ApiError,
|
|
120
|
+
400,
|
|
121
|
+
);
|
|
111
122
|
// The URL stays out of the log: a path may hold something private.
|
|
112
123
|
this.#ports.logger.error({ err: error }, "console api failed");
|
|
113
124
|
return json(
|
|
114
125
|
{
|
|
115
|
-
error:
|
|
126
|
+
error: message(
|
|
127
|
+
this.#ports.presentation,
|
|
128
|
+
"The console could not complete the request.",
|
|
129
|
+
),
|
|
116
130
|
} satisfies ApiError,
|
|
117
131
|
500,
|
|
118
132
|
);
|
|
@@ -135,9 +149,31 @@ export class ConsoleApi {
|
|
|
135
149
|
const [head, id] = parts;
|
|
136
150
|
if (method === "GET" && head === "config" && !id)
|
|
137
151
|
return json(this.config());
|
|
138
|
-
if (
|
|
152
|
+
if (
|
|
153
|
+
method === "GET" &&
|
|
154
|
+
(head === "overview" || head === "dashboard") &&
|
|
155
|
+
!id
|
|
156
|
+
) {
|
|
139
157
|
this.#pane("overview");
|
|
140
|
-
|
|
158
|
+
const view = await this.overview();
|
|
159
|
+
if (head === "overview") return json(view);
|
|
160
|
+
return json({
|
|
161
|
+
agentGuildId: view.guildId,
|
|
162
|
+
agents: view.agents.map((agent) => ({
|
|
163
|
+
...agent,
|
|
164
|
+
key: agent.channelId ? `discord:${agent.channelId}` : "",
|
|
165
|
+
})),
|
|
166
|
+
groups: view.groups.map((group) => ({
|
|
167
|
+
...group,
|
|
168
|
+
key: `discord:${group.channelId}`,
|
|
169
|
+
})),
|
|
170
|
+
workspaces: (view.workspaces ?? []).map((c) => ({
|
|
171
|
+
...c,
|
|
172
|
+
channelId: c.id,
|
|
173
|
+
})),
|
|
174
|
+
outside: (view.outside ?? []).map((c) => ({ ...c, sessionId: c.id })),
|
|
175
|
+
party: view.party ?? [],
|
|
176
|
+
} satisfies DashboardView);
|
|
141
177
|
}
|
|
142
178
|
if (method === "GET" && head === "conversations" && parts.length <= 2) {
|
|
143
179
|
this.#pane("conversations");
|
|
@@ -145,6 +181,23 @@ export class ConsoleApi {
|
|
|
145
181
|
const archive = new URL(request.url).searchParams.get("archive");
|
|
146
182
|
return json(await this.transcript(id, archive ?? undefined));
|
|
147
183
|
}
|
|
184
|
+
if (method === "POST" && head === "channels" && id && parts.length === 3) {
|
|
185
|
+
this.#pane("overview");
|
|
186
|
+
if (parts[2] === "start-over" || parts[2] === "delete")
|
|
187
|
+
return cleanupConversation(this.#ports, id, parts[2]);
|
|
188
|
+
}
|
|
189
|
+
if (method === "GET" && head === "skills" && parts.length <= 2) {
|
|
190
|
+
this.#pane("skills");
|
|
191
|
+
const skills = this.#ports.features?.skills;
|
|
192
|
+
if (!skills) throw new HttpError(404, "This pane is not served.");
|
|
193
|
+
return json(id ? await skillDetail(skills, id) : skillCatalog(skills));
|
|
194
|
+
}
|
|
195
|
+
if (method === "GET" && head === "connectors" && parts.length === 1) {
|
|
196
|
+
this.#pane("connectors");
|
|
197
|
+
const connectors = this.#ports.features?.connectors;
|
|
198
|
+
if (!connectors) throw new HttpError(404, "This pane is not served.");
|
|
199
|
+
return json(await connectorsView(connectors));
|
|
200
|
+
}
|
|
148
201
|
if (head === "notes" && parts.length <= 2) {
|
|
149
202
|
this.#pane("notes");
|
|
150
203
|
return this.#notes(request, id);
|
|
@@ -154,7 +207,18 @@ export class ConsoleApi {
|
|
|
154
207
|
|
|
155
208
|
config(): ConfigView {
|
|
156
209
|
const { title, panes, timeZone } = this.#ports;
|
|
157
|
-
|
|
210
|
+
const { presentation, features } = this.#ports;
|
|
211
|
+
const connectorAdminUrl = safeAdminUrl(features?.connectors?.adminUrl);
|
|
212
|
+
return {
|
|
213
|
+
title,
|
|
214
|
+
panes: [...panes],
|
|
215
|
+
timeZone,
|
|
216
|
+
...(presentation?.locale ? { locale: presentation.locale } : {}),
|
|
217
|
+
...(presentation?.messages ? { messages: presentation.messages } : {}),
|
|
218
|
+
...(features?.cleanup ? { cleanup: true } : {}),
|
|
219
|
+
...(connectorAdminUrl ? { connectorAdminUrl } : {}),
|
|
220
|
+
...(this.#ports.mountPath ? { mountPath: this.#ports.mountPath } : {}),
|
|
221
|
+
};
|
|
158
222
|
}
|
|
159
223
|
|
|
160
224
|
// ── Overview ───────────────────────────────────────────────────────────
|
|
@@ -163,7 +227,23 @@ export class ConsoleApi {
|
|
|
163
227
|
const { team } = this.#ports;
|
|
164
228
|
if (!team) throw new HttpError(404, "This pane is not served.");
|
|
165
229
|
const status = await team.status();
|
|
230
|
+
const { features } = this.#ports;
|
|
231
|
+
const stored = (await this.conversations()).conversations.filter(
|
|
232
|
+
(c) => c.kind === "owner" || c.kind === "outside",
|
|
233
|
+
);
|
|
234
|
+
const workspaces = await Promise.all(
|
|
235
|
+
stored
|
|
236
|
+
.filter((c) => c.kind === "owner")
|
|
237
|
+
.map(async (c) => ({
|
|
238
|
+
...c,
|
|
239
|
+
schedules:
|
|
240
|
+
(await features?.schedules?.count(c.key as ChannelKey)) ?? 0,
|
|
241
|
+
})),
|
|
242
|
+
);
|
|
166
243
|
return {
|
|
244
|
+
workspaces,
|
|
245
|
+
outside: stored.filter((c) => c.kind === "outside"),
|
|
246
|
+
party: (await features?.party?.list()) ?? [],
|
|
167
247
|
guildId: team.guildId,
|
|
168
248
|
agents: status.agents.map((agent) => ({
|
|
169
249
|
name: agent.name,
|
|
@@ -194,7 +274,9 @@ export class ConsoleApi {
|
|
|
194
274
|
async conversations(): Promise<ConversationsView> {
|
|
195
275
|
const { sessionsDir, relayNotes, exclude } = this.#ports;
|
|
196
276
|
const stored = storedConversations(sessionsDir, {
|
|
197
|
-
excluded: (key) =>
|
|
277
|
+
excluded: (key) =>
|
|
278
|
+
(exclude?.(key) ?? false) ||
|
|
279
|
+
(this.#ports.features?.party?.contains(key) ?? false),
|
|
198
280
|
relayNotes,
|
|
199
281
|
});
|
|
200
282
|
return {
|
|
@@ -208,7 +290,11 @@ export class ConsoleApi {
|
|
|
208
290
|
): Promise<TranscriptView> {
|
|
209
291
|
const { sessionsDir, relayNotes, exclude } = this.#ports;
|
|
210
292
|
const parsed = parseKey(key);
|
|
211
|
-
if (
|
|
293
|
+
if (
|
|
294
|
+
!parsed ||
|
|
295
|
+
exclude?.(key as ChannelKey) ||
|
|
296
|
+
this.#ports.features?.party?.contains(key as ChannelKey)
|
|
297
|
+
)
|
|
212
298
|
throw new HttpError(404, "There is no such conversation.");
|
|
213
299
|
const dir = join(sessionsDir, parsed.dir);
|
|
214
300
|
const files = conversationFiles(dir);
|
package/src/console-server.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { HttpRoute, Logger } from "pi-roundtable";
|
|
2
2
|
import type { AssetBundle } from "./assets.ts";
|
|
3
3
|
import type { ConsoleApi } from "./console-api.ts";
|
|
4
|
+
import type { ConsolePresentation } from "./features.ts";
|
|
5
|
+
import { presentPage } from "./page-presentation.ts";
|
|
4
6
|
import type { RequestVerifier } from "./verifier.ts";
|
|
5
7
|
|
|
6
8
|
const encoder = new TextEncoder();
|
|
@@ -39,6 +41,9 @@ export interface ConsoleServerOptions {
|
|
|
39
41
|
logger: Logger;
|
|
40
42
|
/** How long changes are gathered into one event; default 250 ms. Tests set it lower. */
|
|
41
43
|
coalesceMs?: number;
|
|
44
|
+
/** Serve client page paths as the same authenticated document. */
|
|
45
|
+
pathRouting?: boolean;
|
|
46
|
+
presentation?: ConsolePresentation;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
/**
|
|
@@ -106,12 +111,7 @@ export class ConsoleServer {
|
|
|
106
111
|
async #respond(request: Request): Promise<Response> {
|
|
107
112
|
// pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
|
|
108
113
|
const path = new URL(request.url).pathname;
|
|
109
|
-
if (path
|
|
110
|
-
return new Response(null, {
|
|
111
|
-
status: 302,
|
|
112
|
-
headers: { location: this.#base },
|
|
113
|
-
});
|
|
114
|
-
if (!path.startsWith(this.#base))
|
|
114
|
+
if (path !== this.#options.mount && !path.startsWith(this.#base))
|
|
115
115
|
return new Response("Not found", { status: 404 });
|
|
116
116
|
const refusal = await this.#refusal(request);
|
|
117
117
|
if (refusal !== undefined) {
|
|
@@ -121,6 +121,11 @@ export class ConsoleServer {
|
|
|
121
121
|
headers: { "cache-control": "no-store" },
|
|
122
122
|
});
|
|
123
123
|
}
|
|
124
|
+
if (path === this.#options.mount)
|
|
125
|
+
return new Response(null, {
|
|
126
|
+
status: 302,
|
|
127
|
+
headers: { location: this.#base },
|
|
128
|
+
});
|
|
124
129
|
const rest = path.slice(this.#base.length);
|
|
125
130
|
if (rest === "api/events" && request.method === "GET")
|
|
126
131
|
return this.#events();
|
|
@@ -128,18 +133,37 @@ export class ConsoleServer {
|
|
|
128
133
|
return this.#options.api.handle(request, rest.slice("api/".length));
|
|
129
134
|
if (!READ_METHODS.has(request.method))
|
|
130
135
|
return new Response("Method not allowed", { status: 405 });
|
|
131
|
-
if (rest === "" || rest === "index.html") {
|
|
132
|
-
const page = this.#options.assets.get("index.html");
|
|
133
|
-
if (page) return new Response(page.body, { headers: PAGE_HEADERS });
|
|
134
|
-
}
|
|
135
136
|
const asset = this.#options.assets.get(rest);
|
|
136
137
|
if (asset && rest !== "index.html")
|
|
137
|
-
return new Response(asset.body, {
|
|
138
|
+
return new Response(new Uint8Array(asset.body), {
|
|
138
139
|
headers: {
|
|
139
140
|
"content-type": asset.type,
|
|
140
141
|
"cache-control": "private, max-age=31536000, immutable",
|
|
141
142
|
},
|
|
142
143
|
});
|
|
144
|
+
if (rest === "" || rest === "index.html" || this.#options.pathRouting) {
|
|
145
|
+
const page = this.#options.assets.get("index.html");
|
|
146
|
+
if (page)
|
|
147
|
+
return new Response(
|
|
148
|
+
presentPage(
|
|
149
|
+
page.body,
|
|
150
|
+
this.#options.pathRouting ? this.#options.mount : undefined,
|
|
151
|
+
this.#options.presentation,
|
|
152
|
+
),
|
|
153
|
+
{
|
|
154
|
+
headers: {
|
|
155
|
+
...PAGE_HEADERS,
|
|
156
|
+
...(this.#options.pathRouting
|
|
157
|
+
? {
|
|
158
|
+
"content-security-policy": PAGE_HEADERS[
|
|
159
|
+
"content-security-policy"
|
|
160
|
+
].replace("base-uri 'none'", "base-uri 'self'"),
|
|
161
|
+
}
|
|
162
|
+
: {}),
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
}
|
|
143
167
|
return new Response("Not found", { status: 404 });
|
|
144
168
|
}
|
|
145
169
|
|
package/src/conversations.ts
CHANGED
|
@@ -2,9 +2,9 @@ import {
|
|
|
2
2
|
closeSync,
|
|
3
3
|
existsSync,
|
|
4
4
|
fstatSync,
|
|
5
|
+
lstatSync,
|
|
5
6
|
openSync,
|
|
6
7
|
readdirSync,
|
|
7
|
-
readFileSync,
|
|
8
8
|
readSync,
|
|
9
9
|
statSync,
|
|
10
10
|
} from "node:fs";
|
|
@@ -82,21 +82,22 @@ export function parseKey(
|
|
|
82
82
|
* folders, as `archiveSessions` leaves them. Undefined when the directory does not exist.
|
|
83
83
|
*/
|
|
84
84
|
export function conversationFiles(dir: string): ConversationFiles | undefined {
|
|
85
|
-
if (!existsSync(dir)) return undefined;
|
|
85
|
+
if (!existsSync(dir) || !lstatSync(dir).isDirectory()) return undefined;
|
|
86
86
|
let liveBytes = 0;
|
|
87
87
|
let newest = 0;
|
|
88
|
-
for (const file of readdirSync(dir)) {
|
|
89
|
-
if (!file.endsWith(".jsonl")) continue;
|
|
90
|
-
const stat = statSync(join(dir, file));
|
|
88
|
+
for (const file of readdirSync(dir, { withFileTypes: true })) {
|
|
89
|
+
if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
|
|
90
|
+
const stat = statSync(join(dir, file.name));
|
|
91
91
|
liveBytes += stat.size;
|
|
92
92
|
newest = Math.max(newest, stat.mtimeMs);
|
|
93
93
|
}
|
|
94
94
|
const archiveDir = join(dir, "archive");
|
|
95
|
-
const archives =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
const archives =
|
|
96
|
+
existsSync(archiveDir) && lstatSync(archiveDir).isDirectory()
|
|
97
|
+
? readdirSync(archiveDir, { withFileTypes: true }).filter((entry) =>
|
|
98
|
+
entry.isDirectory(),
|
|
99
|
+
)
|
|
100
|
+
: [];
|
|
100
101
|
if (newest === 0)
|
|
101
102
|
for (const archive of archives)
|
|
102
103
|
newest = Math.max(
|
|
@@ -133,8 +134,9 @@ function withoutRelayNote(text: string, notes: readonly string[]): string {
|
|
|
133
134
|
|
|
134
135
|
/** The `.jsonl` file names directly in a directory, oldest first; names start with their creation time. */
|
|
135
136
|
function sessionFiles(dir: string): string[] {
|
|
136
|
-
return readdirSync(dir)
|
|
137
|
-
.filter((file) => file.endsWith(".jsonl"))
|
|
137
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
138
|
+
.filter((file) => file.isFile() && file.name.endsWith(".jsonl"))
|
|
139
|
+
.map((file) => file.name)
|
|
138
140
|
.sort();
|
|
139
141
|
}
|
|
140
142
|
|
|
@@ -164,8 +166,11 @@ function opening(
|
|
|
164
166
|
const archiveDir = join(dir, "archive");
|
|
165
167
|
const first = sessionFiles(dir)[0];
|
|
166
168
|
let file = first ? join(dir, first) : undefined;
|
|
167
|
-
if (!file && existsSync(archiveDir))
|
|
168
|
-
for (const archive of readdirSync(archiveDir
|
|
169
|
+
if (!file && existsSync(archiveDir) && lstatSync(archiveDir).isDirectory())
|
|
170
|
+
for (const archive of readdirSync(archiveDir, { withFileTypes: true })
|
|
171
|
+
.filter((entry) => entry.isDirectory())
|
|
172
|
+
.map((entry) => entry.name)
|
|
173
|
+
.sort()) {
|
|
169
174
|
const name = sessionFiles(join(archiveDir, archive))[0];
|
|
170
175
|
if (name) {
|
|
171
176
|
file = join(archiveDir, archive, name);
|
|
@@ -251,7 +256,8 @@ export function storedConversations(
|
|
|
251
256
|
/** The archive folders of a conversation, newest first. */
|
|
252
257
|
export function archiveNames(dir: string): string[] {
|
|
253
258
|
const archiveDir = join(dir, "archive");
|
|
254
|
-
if (!existsSync(archiveDir)
|
|
259
|
+
if (!existsSync(archiveDir) || !lstatSync(archiveDir).isDirectory())
|
|
260
|
+
return [];
|
|
255
261
|
return readdirSync(archiveDir, { withFileTypes: true })
|
|
256
262
|
.filter((entry) => entry.isDirectory())
|
|
257
263
|
.map((entry) => entry.name)
|
|
@@ -270,12 +276,12 @@ function readTail(
|
|
|
270
276
|
const fd = openSync(path, "r");
|
|
271
277
|
try {
|
|
272
278
|
const { size } = fstatSync(fd);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
return { text: text.slice(text.indexOf("\n") + 1)
|
|
279
|
+
const length = Math.min(size, limit);
|
|
280
|
+
const buffer = Buffer.alloc(length);
|
|
281
|
+
const read = readSync(fd, buffer, 0, length, Math.max(0, size - limit));
|
|
282
|
+
const text = buffer.subarray(0, read).toString("utf8");
|
|
283
|
+
const cut = size > limit;
|
|
284
|
+
return { text: cut ? text.slice(text.indexOf("\n") + 1) : text, cut, read };
|
|
279
285
|
} finally {
|
|
280
286
|
closeSync(fd);
|
|
281
287
|
}
|
package/src/features.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ChannelKey } from "pi-roundtable";
|
|
2
|
+
import type { ConnectorsView, PartyView, SkillView } from "./api-types.ts";
|
|
3
|
+
|
|
4
|
+
/** Trusted host integrations. They receive validated keys, never request-selected file paths. */
|
|
5
|
+
export interface ConsoleFeatures {
|
|
6
|
+
party?: {
|
|
7
|
+
contains(key: ChannelKey): boolean;
|
|
8
|
+
/** The host binds its channel store, runtime status and existing session paths here. */
|
|
9
|
+
list(): Promise<PartyView[]>;
|
|
10
|
+
};
|
|
11
|
+
schedules?: { count(key: ChannelKey): Promise<number> };
|
|
12
|
+
cleanup?: {
|
|
13
|
+
/** Archives the conversation, preserving memory; the host waits for active turns. */
|
|
14
|
+
startFresh(key: ChannelKey): Promise<string>;
|
|
15
|
+
/** Only stored owner/outside conversations are admitted by the console. */
|
|
16
|
+
deleteConversation(key: ChannelKey): Promise<"deleted" | "busy">;
|
|
17
|
+
};
|
|
18
|
+
skills?: {
|
|
19
|
+
catalog(): SkillView[];
|
|
20
|
+
/** Read only the selected catalog entry, with a host-enforced byte limit. */
|
|
21
|
+
read(
|
|
22
|
+
name: string,
|
|
23
|
+
): Promise<{ frontmatter: Record<string, unknown>; body: string }>;
|
|
24
|
+
};
|
|
25
|
+
connectors?: {
|
|
26
|
+
gateways(): Promise<ConnectorsView["gateways"]>;
|
|
27
|
+
servers(): Promise<{ name: string; tools: string[] }[]>;
|
|
28
|
+
usedBy(server: string): string[];
|
|
29
|
+
/** Optional administrator link, limited to HTTP(S) or an absolute local path. */
|
|
30
|
+
adminUrl?: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** English source strings are keys; a host supplies its own wording without a page fork. */
|
|
35
|
+
export interface ConsolePresentation {
|
|
36
|
+
locale?: string;
|
|
37
|
+
messages?: Readonly<Record<string, string>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function message(
|
|
41
|
+
presentation: ConsolePresentation | undefined,
|
|
42
|
+
source: string,
|
|
43
|
+
): string {
|
|
44
|
+
return presentation?.messages?.[source] ?? source;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function safeAdminUrl(url: string | undefined): string | undefined {
|
|
48
|
+
if (!url) return undefined;
|
|
49
|
+
if (url.startsWith("/") && !url.startsWith("//") && !url.includes("\\"))
|
|
50
|
+
return url;
|
|
51
|
+
try {
|
|
52
|
+
const parsed = new URL(url);
|
|
53
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:"
|
|
54
|
+
? parsed.href
|
|
55
|
+
: undefined;
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Fixed errors shared by console API adapters; request paths and upstream details stay private. */
|
|
2
|
+
export class ConsoleHttpError extends Error {
|
|
3
|
+
constructor(
|
|
4
|
+
readonly status: number,
|
|
5
|
+
message: string,
|
|
6
|
+
) {
|
|
7
|
+
super(message);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export const json = (body: unknown, status = 200): Response =>
|
|
11
|
+
Response.json(body, { status, headers: { "cache-control": "no-store" } });
|